Files
star-erp/app/Modules/Procurement/Controllers/PurchaseOrderController.php
sky121113 b0848a6bb8 chore: 完善模組化架構遷移與修復前端顯示錯誤
- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...)
- 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯
- 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題
- 清除全域路徑與 Controller 遷移殘留檔案
2026-01-26 10:37:47 +08:00

557 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseOrderController extends Controller
{
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// Search
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('vendor', function($vq) use ($request) {
$vq->where('name', 'like', "%{$request->search}%");
});
});
}
// Filters
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
$query->where('warehouse_id', $request->warehouse_id);
}
// Date Range
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
if ($request->date_end) {
$query->whereDate('created_at', '<=', $request->date_end);
}
// Sorting
$sortField = $request->sort_field ?? 'id';
$sortDirection = $request->sort_direction ?? 'desc';
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection);
}
$perPage = $request->input('per_page', 10);
$orders = $query->paginate($perPage)->withQueryString();
return Inertia::render('PurchaseOrder/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
'warehouses' => Warehouse::all(['id', 'name']),
]);
}
public function create()
{
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
'items.*.unitId' => 'nullable|exists:units,id',
'tax_amount' => 'nullable|numeric|min:0',
]);
try {
DB::beginTransaction();
// 生成單號YYYYMMDD001
$today = now()->format('Ymd');
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 3 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
}
$code = $today . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
}
// Tax calculation
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = \App\Modules\Core\Models\User::first();
if (!$user) {
$user = \App\Modules\Core\Models\User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
$userId = $user->id;
}
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
}
}
public function show($id)
{
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
$order->items->transform(function ($item) use ($order) {
$product = $item->product;
if ($product) {
// 手動附加所有必要的屬性
$item->productId = (string) $product->id;
$item->productName = $product->name;
$item->base_unit_id = $product->base_unit_id;
$item->base_unit_name = $product->baseUnit?->name;
$item->large_unit_id = $product->large_unit_id;
$item->large_unit_name = $product->largeUnit?->name;
$item->purchase_unit_id = $product->purchase_unit_id;
$item->conversion_rate = (float) $product->conversion_rate;
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $order->vendor_id)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 設定當前選中的單位 ID (from saved item)
$item->unitId = $item->unit_id;
// 決定 selectedUnit (用於 UI 顯示)
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
$item->selectedUnit = 'large';
} else {
$item->selectedUnit = 'base';
}
$item->unitPrice = (float) $item->unit_price;
}
return $item;
});
return Inertia::render('PurchaseOrder/Show', [
'order' => $order
]);
}
public function edit($id)
{
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
// Transform items for frontend form
// Transform items for frontend form
$vendorId = $order->vendor_id;
$order->items->transform(function ($item) use ($vendorId) {
$product = $item->product;
if ($product) {
// 手動附加所有必要的屬性
$item->productId = (string) $product->id;
$item->productName = $product->name;
$item->base_unit_id = $product->base_unit_id;
$item->base_unit_name = $product->baseUnit?->name;
$item->large_unit_id = $product->large_unit_id;
$item->large_unit_name = $product->largeUnit?->name;
$item->conversion_rate = (float) $product->conversion_rate;
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 設定當前選中的單位 ID
$item->unitId = $item->unit_id; // 資料庫中的 unit_id
// 決定 selectedUnit (用於 UI 狀態)
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
$item->selectedUnit = 'large';
} else {
$item->selectedUnit = 'base';
}
$item->unitPrice = (float) $item->unit_price;
}
return $item;
});
return Inertia::render('PurchaseOrder/Create', [
'order' => $order,
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function update(Request $request, $id)
{
$order = PurchaseOrder::findOrFail($id);
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
'items.*.unitId' => 'nullable|exists:units,id',
// Allow both tax_amount and taxAmount for compatibility
'tax_amount' => 'nullable|numeric|min:0',
'taxAmount' => 'nullable|numeric|min:0',
]);
try {
DB::beginTransaction();
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
}
// Tax calculation (handle both keys)
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 1. Fill attributes but don't save yet to capture changes
$order->fill([
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'status' => $validated['status'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
// Capture attribute changes for manual logging
$dirty = $order->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $order->getOriginal($key);
$newAttributes[$key] = $value;
}
// Save without triggering events (prevents duplicate log)
$order->saveQuietly();
// 2. Capture old items with product names for diffing
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $item->product?->name,
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'subtotal' => (float) $item->subtotal,
];
})->keyBy('product_id');
// Sync items (Original logic)
$order->items()->delete();
$newItemsData = [];
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$newItem = $order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
$newItemsData[] = $newItem;
}
// 3. Calculate Item Diffs
$itemDiffs = [
'added' => [],
'removed' => [],
'updated' => [],
];
// Re-fetch new items to ensure we have fresh relations
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->name,
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'subtotal' => (float) $item->subtotal,
];
})->keyBy('product_id');
// Find removed
foreach ($oldItems as $productId => $oldItem) {
if (!$newItemsFormatted->has($productId)) {
$itemDiffs['removed'][] = $oldItem;
}
}
// Find added and updated
foreach ($newItemsFormatted as $productId => $newItem) {
if (!$oldItems->has($productId)) {
$itemDiffs['added'][] = $newItem;
} else {
$oldItem = $oldItems[$productId];
// Compare fields
if (
$oldItem['quantity'] != $newItem['quantity'] ||
$oldItem['unit_id'] != $newItem['unit_id'] ||
$oldItem['subtotal'] != $newItem['subtotal']
) {
$itemDiffs['updated'][] = [
'product_name' => $newItem['product_name'],
'old' => [
'quantity' => $oldItem['quantity'],
'unit_name' => $oldItem['unit_name'],
'subtotal' => $oldItem['subtotal'],
],
'new' => [
'quantity' => $newItem['quantity'],
'unit_name' => $newItem['unit_name'],
'subtotal' => $newItem['subtotal'],
]
];
}
}
}
// 4. Manually Log activity (Single Consolidated Log)
// Log if there are attribute changes OR item changes
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $newAttributes,
'old' => $oldAttributes,
'items_diff' => $itemDiffs,
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('updated');
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
}
}
public function destroy($id)
{
try {
DB::beginTransaction();
$order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id);
// Capture items for logging
$items = $order->items->map(function ($item) {
return [
'product_name' => $item->product_name,
'quantity' => floatval($item->quantity),
'unit_name' => $item->unit_name,
'subtotal' => floatval($item->subtotal),
];
})->toArray();
// Manually log the deletion with items
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('deleted')
->withProperties([
'attributes' => $order->getAttributes(),
'items_diff' => [
'added' => [],
'removed' => $items,
'updated' => [],
],
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('deleted');
// Disable automatic logging for this operation
$order->disableLogging();
// Delete associated items first
$order->items()->delete();
$order->delete();
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
}
}
}