From e5edad4fd0b429111871d808d0dc7eaed4afd281 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 28 Jan 2026 18:04:45 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E4=BF=AE=E6=AD=A3=E7=9B=A4=E9=BB=9E?= =?UTF-8?q?=E8=88=87=E7=9B=A4=E8=AA=BF=E7=95=AB=E9=9D=A2=20Table=20Padding?= =?UTF-8?q?=20=E4=B8=A6=E7=B5=B1=E4=B8=80=20UI=20=E8=A6=8F=E7=AF=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AdjustDocController.php | 208 +++++++ .../Controllers/CountDocController.php | 158 ++++++ .../Controllers/TransferOrderController.php | 228 ++++---- .../Inventory/Models/InventoryAdjustDoc.php | 81 +++ .../Inventory/Models/InventoryAdjustItem.php | 36 ++ .../Inventory/Models/InventoryCountDoc.php | 78 +++ .../Inventory/Models/InventoryCountItem.php | 38 ++ .../Models/InventoryTransferItem.php | 34 ++ .../Models/InventoryTransferOrder.php | 66 +++ app/Modules/Inventory/Routes/web.php | 34 +- .../Inventory/Services/AdjustService.php | 153 ++++++ .../Inventory/Services/CountService.php | 156 ++++++ .../Inventory/Services/TransferService.php | 152 ++++++ ...8_143811_create_inventory_count_tables.php | 54 ++ ..._150608_create_inventory_adjust_tables.php | 53 ++ ...51141_create_inventory_transfer_tables.php | 47 ++ ..._count_doc_id_to_inventory_adjust_docs.php | 32 ++ resources/js/Layouts/AuthenticatedLayout.tsx | 32 +- resources/js/Pages/Inventory/Adjust/Index.tsx | 409 ++++++++++++++ resources/js/Pages/Inventory/Adjust/Show.tsx | 511 ++++++++++++++++++ resources/js/Pages/Inventory/Count/Index.tsx | 324 +++++++++++ resources/js/Pages/Inventory/Count/Show.tsx | 275 ++++++++++ .../js/Pages/Inventory/Transfer/Index.tsx | 255 +++++++++ .../js/Pages/Inventory/Transfer/Show.tsx | 336 ++++++++++++ 24 files changed, 3648 insertions(+), 102 deletions(-) create mode 100644 app/Modules/Inventory/Controllers/AdjustDocController.php create mode 100644 app/Modules/Inventory/Controllers/CountDocController.php create mode 100644 app/Modules/Inventory/Models/InventoryAdjustDoc.php create mode 100644 app/Modules/Inventory/Models/InventoryAdjustItem.php create mode 100644 app/Modules/Inventory/Models/InventoryCountDoc.php create mode 100644 app/Modules/Inventory/Models/InventoryCountItem.php create mode 100644 app/Modules/Inventory/Models/InventoryTransferItem.php create mode 100644 app/Modules/Inventory/Models/InventoryTransferOrder.php create mode 100644 app/Modules/Inventory/Services/AdjustService.php create mode 100644 app/Modules/Inventory/Services/CountService.php create mode 100644 app/Modules/Inventory/Services/TransferService.php create mode 100644 database/migrations/tenant/2026_01_28_143811_create_inventory_count_tables.php create mode 100644 database/migrations/tenant/2026_01_28_150608_create_inventory_adjust_tables.php create mode 100644 database/migrations/tenant/2026_01_28_151141_create_inventory_transfer_tables.php create mode 100644 database/migrations/tenant/2026_01_28_173242_add_count_doc_id_to_inventory_adjust_docs.php create mode 100644 resources/js/Pages/Inventory/Adjust/Index.tsx create mode 100644 resources/js/Pages/Inventory/Adjust/Show.tsx create mode 100644 resources/js/Pages/Inventory/Count/Index.tsx create mode 100644 resources/js/Pages/Inventory/Count/Show.tsx create mode 100644 resources/js/Pages/Inventory/Transfer/Index.tsx create mode 100644 resources/js/Pages/Inventory/Transfer/Show.tsx diff --git a/app/Modules/Inventory/Controllers/AdjustDocController.php b/app/Modules/Inventory/Controllers/AdjustDocController.php new file mode 100644 index 0000000..be7f96c --- /dev/null +++ b/app/Modules/Inventory/Controllers/AdjustDocController.php @@ -0,0 +1,208 @@ +adjustService = $adjustService; + } + + public function index(Request $request) + { + $query = InventoryAdjustDoc::query() + ->with(['createdBy', 'postedBy', 'warehouse']); + + // 搜尋 + if ($request->filled('search')) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('doc_no', 'like', "%{$search}%") + ->orWhere('reason', 'like', "%{$search}%") + ->orWhere('remarks', 'like', "%{$search}%"); + }); + } + + if ($request->filled('warehouse_id')) { + $query->where('warehouse_id', $request->warehouse_id); + } + + $perPage = $request->input('per_page', 15); + $docs = $query->orderByDesc('created_at') + ->paginate($perPage) + ->withQueryString() + ->through(function ($doc) { + return [ + 'id' => (string) $doc->id, + 'doc_no' => $doc->doc_no, + 'status' => $doc->status, + 'warehouse_name' => $doc->warehouse->name, + 'reason' => $doc->reason, + 'created_at' => $doc->created_at->format('Y-m-d H:i'), + 'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-', + 'created_by' => $doc->createdBy?->name, + 'remarks' => $doc->remarks, + ]; + }); + + return Inertia::render('Inventory/Adjust/Index', [ + 'docs' => $docs, + 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'filters' => $request->only(['warehouse_id', 'search', 'per_page']), + ]); + } + + public function store(Request $request) + { + // 模式 1: 從盤點單建立 + if ($request->filled('count_doc_id')) { + $countDoc = InventoryCountDoc::findOrFail($request->count_doc_id); + + // 檢查是否已存在對應的盤調單 (避免重複建立) + if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) { + return redirect()->back()->with('error', '此盤點單已建立過盤調單'); + } + + $doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id()); + return redirect()->route('inventory.adjust.show', [$doc->id]) + ->with('success', '已從盤點單生成盤調單'); + } + + // 模式 2: 一般手動調整 (保留原始邏輯但更新訊息) + $validated = $request->validate([ + 'warehouse_id' => 'required', + 'reason' => 'required|string', + 'remarks' => 'nullable|string', + ]); + + $doc = $this->adjustService->createDoc( + $validated['warehouse_id'], + $validated['reason'], + $validated['remarks'], + auth()->id() + ); + + return redirect()->route('inventory.adjust.show', [$doc->id]) + ->with('success', '已建立盤調單'); + } + + /** + * API: 獲取可盤調的已完成盤點單 (支援掃描單號) + */ + public function getPendingCounts(Request $request) + { + $query = InventoryCountDoc::where('status', 'completed') + ->whereNotExists(function ($query) { + $query->select(DB::raw(1)) + ->from('inventory_adjust_docs') + ->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id'); + }); + + if ($request->filled('search')) { + $search = $request->search; + $query->where('doc_no', 'like', "%{$search}%"); + } + + $counts = $query->limit(10)->get()->map(function($c) { + return [ + 'id' => (string)$c->id, + 'doc_no' => $c->doc_no, + 'warehouse_name' => $c->warehouse->name, + 'completed_at' => $c->completed_at->format('Y-m-d H:i'), + ]; + }); + + return response()->json($counts); + } + + public function show(InventoryAdjustDoc $doc) + { + $doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse']); + + $docData = [ + 'id' => (string) $doc->id, + 'doc_no' => $doc->doc_no, + 'warehouse_id' => (string) $doc->warehouse_id, + 'warehouse_name' => $doc->warehouse->name, + 'status' => $doc->status, + 'reason' => $doc->reason, + 'remarks' => $doc->remarks, + 'created_at' => $doc->created_at->format('Y-m-d H:i'), + 'created_by' => $doc->createdBy?->name, + 'items' => $doc->items->map(function ($item) { + return [ + 'id' => (string) $item->id, + 'product_id' => (string) $item->product_id, + 'product_name' => $item->product->name, + 'product_code' => $item->product->code, + 'batch_number' => $item->batch_number, + 'unit' => $item->product->baseUnit?->name, + 'qty_before' => (float) $item->qty_before, + 'adjust_qty' => (float) $item->adjust_qty, + 'notes' => $item->notes, + ]; + }), + ]; + + return Inertia::render('Inventory/Adjust/Show', [ + 'doc' => $docData, + ]); + } + + public function update(Request $request, InventoryAdjustDoc $doc) + { + if ($doc->status !== 'draft') { + return redirect()->back()->with('error', '只能修改草稿狀態的單據'); + } + + // 提交 (items 更新 或 過帳) + if ($request->input('action') === 'post') { + $this->adjustService->post($doc, auth()->id()); + return redirect()->route('inventory.adjust.index') + ->with('success', '調整單已過帳生效'); + } + + // 僅儲存資料 + $validated = $request->validate([ + 'items' => 'array', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.adjust_qty' => 'required|numeric', // 可以是負數 + 'items.*.batch_number' => 'nullable|string', + 'items.*.notes' => 'nullable|string', + ]); + + if ($request->has('items')) { + $this->adjustService->updateItems($doc, $validated['items']); + } + + // 更新表頭 + $doc->update($request->only(['reason', 'remarks'])); + + return redirect()->back()->with('success', '儲存成功'); + } + + public function destroy(InventoryAdjustDoc $doc) + { + if ($doc->status !== 'draft') { + return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); + } + + $doc->items()->delete(); + $doc->delete(); + + return redirect()->route('inventory.adjust.index') + ->with('success', '調整單已刪除'); + } +} diff --git a/app/Modules/Inventory/Controllers/CountDocController.php b/app/Modules/Inventory/Controllers/CountDocController.php new file mode 100644 index 0000000..f33edad --- /dev/null +++ b/app/Modules/Inventory/Controllers/CountDocController.php @@ -0,0 +1,158 @@ +countService = $countService; + } + + public function index(Request $request) + { + $query = InventoryCountDoc::query() + ->with(['createdBy', 'completedBy', 'warehouse']); + + if ($request->filled('warehouse_id')) { + $query->where('warehouse_id', $request->warehouse_id); + } + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('doc_no', 'like', "%{$search}%") + ->orWhere('remarks', 'like', "%{$search}%"); + }); + } + + $perPage = $request->input('per_page', 10); + if (!in_array($perPage, [10, 20, 50, 100])) { + $perPage = 15; + } + + $docs = $query->orderByDesc('created_at') + ->paginate($perPage) + ->withQueryString() + ->through(function ($doc) { + return [ + 'id' => (string) $doc->id, + 'doc_no' => $doc->doc_no, + 'status' => $doc->status, + 'warehouse_name' => $doc->warehouse->name, + 'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-', + 'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-', + 'created_by' => $doc->createdBy?->name, + 'remarks' => $doc->remarks, + ]; + }); + + return Inertia::render('Inventory/Count/Index', [ + 'docs' => $docs, + 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'filters' => $request->only(['warehouse_id', 'search', 'per_page']), + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'warehouse_id' => 'required|exists:warehouses,id', + 'remarks' => 'nullable|string|max:255', + ]); + + $doc = $this->countService->createDoc( + $validated['warehouse_id'], + $validated['remarks'] ?? null, + auth()->id() + ); + + // 自動執行快照 + $this->countService->snapshot($doc); + + return redirect()->route('inventory.count.show', [$doc->id]) + ->with('success', '已建立盤點單並完成庫存快照'); + } + + public function show(InventoryCountDoc $doc) + { + $doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']); + + $docData = [ + 'id' => (string) $doc->id, + 'doc_no' => $doc->doc_no, + 'warehouse_id' => (string) $doc->warehouse_id, + 'warehouse_name' => $doc->warehouse->name, + 'status' => $doc->status, + 'remarks' => $doc->remarks, + 'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null, + 'created_by' => $doc->createdBy?->name, + 'items' => $doc->items->map(function ($item) { + return [ + 'id' => (string) $item->id, + 'product_name' => $item->product->name, + 'product_code' => $item->product->code, + 'batch_number' => $item->batch_number, + 'unit' => $item->product->baseUnit?->name, + 'system_qty' => (float) $item->system_qty, + 'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty, + 'diff_qty' => (float) $item->diff_qty, + 'notes' => $item->notes, + ]; + }), + ]; + + return Inertia::render('Inventory/Count/Show', [ + 'doc' => $docData, + ]); + } + + public function update(Request $request, InventoryCountDoc $doc) + { + if ($doc->status === 'completed') { + return redirect()->back()->with('error', '此盤點單已完成,無法修改'); + } + + $validated = $request->validate([ + 'items' => 'array', + 'items.*.id' => 'required|exists:inventory_count_items,id', + 'items.*.counted_qty' => 'nullable|numeric|min:0', + 'items.*.notes' => 'nullable|string', + ]); + + if (isset($validated['items'])) { + $this->countService->updateCount($doc, $validated['items']); + } + + // 如果是按了 "完成盤點" + if ($request->input('action') === 'complete') { + $this->countService->complete($doc, auth()->id()); + return redirect()->route('inventory.count.index') + ->with('success', '盤點已完成並過帳'); + } + + return redirect()->back()->with('success', '暫存成功'); + } + + public function destroy(InventoryCountDoc $doc) + { + if ($doc->status === 'completed') { + return redirect()->back()->with('error', '已完成的盤點單無法刪除'); + } + + $doc->items()->delete(); + $doc->delete(); + + return redirect()->route('inventory.count.index') + ->with('success', '盤點單已刪除'); + } +} diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index d93c6ec..86cbd48 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -3,135 +3,171 @@ namespace App\Modules\Inventory\Controllers; use App\Http\Controllers\Controller; - -use App\Modules\Inventory\Models\Inventory; +use App\Modules\Inventory\Models\InventoryTransferOrder; use App\Modules\Inventory\Models\Warehouse; +use App\Modules\Inventory\Services\TransferService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; +use Inertia\Inertia; class TransferOrderController extends Controller { - /** - * 儲存撥補單(建立調撥單並執行庫存轉移) - */ + protected $transferService; + + public function __construct(TransferService $transferService) + { + $this->transferService = $transferService; + } + + public function index(Request $request) + { + $query = InventoryTransferOrder::query() + ->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']); + + // 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單 + if ($request->filled('warehouse_id')) { + $query->where(function ($q) use ($request) { + $q->where('from_warehouse_id', $request->warehouse_id) + ->orWhere('to_warehouse_id', $request->warehouse_id); + }); + } + + $orders = $query->orderByDesc('created_at') + ->paginate(15) + ->through(function ($order) { + return [ + 'id' => (string) $order->id, + 'doc_no' => $order->doc_no, + 'from_warehouse_name' => $order->fromWarehouse->name, + 'to_warehouse_name' => $order->toWarehouse->name, + 'status' => $order->status, + 'created_at' => $order->created_at->format('Y-m-d H:i'), + 'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-', + 'created_by' => $order->createdBy?->name, + ]; + }); + + return Inertia::render('Inventory/Transfer/Index', [ + 'orders' => $orders, + 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'filters' => $request->only(['warehouse_id']), + ]); + } + public function store(Request $request) { $validated = $request->validate([ - 'sourceWarehouseId' => 'required|exists:warehouses,id', - 'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId', - 'productId' => 'required|exists:products,id', - 'quantity' => 'required|numeric|min:0.01', - 'transferDate' => 'required|date', - 'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄 - 'notes' => 'nullable|string', - 'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存 + 'from_warehouse_id' => 'required|exists:warehouses,id', + 'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id', + 'remarks' => 'nullable|string', ]); - return DB::transaction(function () use ($validated) { - // 1. 檢查來源倉庫庫存 (精確匹配產品與批號) - $sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId']) - ->where('product_id', $validated['productId']) - ->where('batch_number', $validated['batchNumber']) - ->first(); + $order = $this->transferService->createOrder( + $validated['from_warehouse_id'], + $validated['to_warehouse_id'], + $validated['remarks'] ?? null, + auth()->id() + ); - if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) { - throw ValidationException::withMessages([ - 'quantity' => ['來源倉庫指定批號庫存不足'], - ]); + return redirect()->route('inventory.transfer.show', [$order->id]) + ->with('success', '已建立調撥單'); + } + + public function show(InventoryTransferOrder $order) + { + $order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']); + + $orderData = [ + 'id' => (string) $order->id, + 'doc_no' => $order->doc_no, + 'from_warehouse_id' => (string) $order->from_warehouse_id, + 'from_warehouse_name' => $order->fromWarehouse->name, + 'to_warehouse_id' => (string) $order->to_warehouse_id, + 'to_warehouse_name' => $order->toWarehouse->name, + 'status' => $order->status, + 'remarks' => $order->remarks, + 'created_at' => $order->created_at->format('Y-m-d H:i'), + 'created_by' => $order->createdBy?->name, + 'items' => $order->items->map(function ($item) { + return [ + 'id' => (string) $item->id, + 'product_id' => (string) $item->product_id, + 'product_name' => $item->product->name, + 'product_code' => $item->product->code, + 'batch_number' => $item->batch_number, + 'unit' => $item->product->baseUnit?->name, + 'quantity' => (float) $item->quantity, + 'notes' => $item->notes, + ]; + }), + ]; + + return Inertia::render('Inventory/Transfer/Show', [ + 'order' => $orderData, + ]); + } + + public function update(Request $request, InventoryTransferOrder $order) + { + if ($order->status !== 'draft') { + return redirect()->back()->with('error', '只能修改草稿狀態的單據'); + } + + if ($request->input('action') === 'post') { + try { + $this->transferService->post($order, auth()->id()); + return redirect()->route('inventory.transfer.index') + ->with('success', '調撥單已過帳完成'); + } catch (\Exception $e) { + return redirect()->back()->withErrors(['items' => $e->getMessage()]); } + } - // 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態) - $targetInventory = Inventory::firstOrCreate( - [ - 'warehouse_id' => $validated['targetWarehouseId'], - 'product_id' => $validated['productId'], - 'batch_number' => $validated['batchNumber'], - ], - [ - 'quantity' => 0, - 'unit_cost' => $sourceInventory->unit_cost, // 繼承成本 - 'total_value' => 0, - 'expiry_date' => $sourceInventory->expiry_date, - 'quality_status' => $sourceInventory->quality_status, - 'origin_country' => $sourceInventory->origin_country, - ] - ); + $validated = $request->validate([ + 'items' => 'array', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.batch_number' => 'nullable|string', + 'items.*.notes' => 'nullable|string', + ]); - $sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']); - $targetWarehouse = Warehouse::find($validated['targetWarehouseId']); + if ($request->has('items')) { + $this->transferService->updateItems($order, $validated['items']); + } - // 3. 執行庫存轉移 (扣除來源) - $oldSourceQty = $sourceInventory->quantity; - $newSourceQty = $oldSourceQty - $validated['quantity']; - - // 設定活動紀錄原因 - $sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}"; - $sourceInventory->quantity = $newSourceQty; - $sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值 - $sourceInventory->save(); + $order->update($request->only(['remarks'])); - // 記錄來源異動 - $sourceInventory->transactions()->create([ - 'type' => '撥補出庫', - 'quantity' => -$validated['quantity'], - 'unit_cost' => $sourceInventory->unit_cost, // 記錄 - 'balance_before' => $oldSourceQty, - 'balance_after' => $newSourceQty, - 'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""), - 'actual_time' => $validated['transferDate'], - 'user_id' => auth()->id(), - ]); + return redirect()->back()->with('success', '儲存成功'); + } - // 4. 執行庫存轉移 (增加目標) - $oldTargetQty = $targetInventory->quantity; - $newTargetQty = $oldTargetQty + $validated['quantity']; + public function destroy(InventoryTransferOrder $order) + { + if ($order->status !== 'draft') { + return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); + } + $order->items()->delete(); + $order->delete(); - // 設定活動紀錄原因 - $targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}"; - // 確保目標庫存也有成本 (如果是繼承來的) - if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) { - $targetInventory->unit_cost = $sourceInventory->unit_cost; - } - $targetInventory->quantity = $newTargetQty; - $targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值 - $targetInventory->save(); - - // 記錄目標異動 - $targetInventory->transactions()->create([ - 'type' => '撥補入庫', - 'quantity' => $validated['quantity'], - 'unit_cost' => $targetInventory->unit_cost, // 記錄 - 'balance_before' => $oldTargetQty, - 'balance_after' => $newTargetQty, - 'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""), - 'actual_time' => $validated['transferDate'], - 'user_id' => auth()->id(), - ]); - - // TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄 - - return redirect()->back()->with('success', '撥補單已建立且庫存已轉移'); - }); + return redirect()->route('inventory.transfer.index') + ->with('success', '調撥單已刪除'); } /** - * 獲取特定倉庫的庫存列表 (API) + * 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用 */ public function getWarehouseInventories(Warehouse $warehouse) { $inventories = $warehouse->inventories() ->with(['product.baseUnit', 'product.category']) - ->where('quantity', '>', 0) // 只回傳有庫存的 + ->where('quantity', '>', 0) ->get() ->map(function ($inv) { return [ 'product_id' => (string) $inv->product_id, 'product_name' => $inv->product->name, + 'product_code' => $inv->product->code, // Added code 'batch_number' => $inv->batch_number, 'quantity' => (float) $inv->quantity, - 'unit_cost' => (float) $inv->unit_cost, // 新增 - 'total_value' => (float) $inv->total_value, // 新增 + 'unit_cost' => (float) $inv->unit_cost, 'unit_name' => $inv->product->baseUnit?->name ?? '個', 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, ]; diff --git a/app/Modules/Inventory/Models/InventoryAdjustDoc.php b/app/Modules/Inventory/Models/InventoryAdjustDoc.php new file mode 100644 index 0000000..4b307fc --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryAdjustDoc.php @@ -0,0 +1,81 @@ + 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->doc_no)) { + $today = date('Ymd'); + $prefix = 'ADJ' . $today; + + $lastDoc = static::where('doc_no', 'like', $prefix . '%') + ->orderBy('doc_no', 'desc') + ->first(); + + if ($lastDoc) { + $lastNumber = substr($lastDoc->doc_no, -2); + $nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT); + } else { + $nextNumber = '01'; + } + + $model->doc_no = $prefix . $nextNumber; + } + }); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function countDoc(): BelongsTo + { + return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id'); + } + + public function items(): HasMany + { + return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function postedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'posted_by'); + } +} diff --git a/app/Modules/Inventory/Models/InventoryAdjustItem.php b/app/Modules/Inventory/Models/InventoryAdjustItem.php new file mode 100644 index 0000000..843371d --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryAdjustItem.php @@ -0,0 +1,36 @@ + 'decimal:2', + 'adjust_qty' => 'decimal:2', + ]; + + public function doc(): BelongsTo + { + return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Modules/Inventory/Models/InventoryCountDoc.php b/app/Modules/Inventory/Models/InventoryCountDoc.php new file mode 100644 index 0000000..3ee6dec --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryCountDoc.php @@ -0,0 +1,78 @@ + 'datetime', + 'completed_at' => 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->doc_no)) { + $today = date('Ymd'); + $prefix = 'CNT' . $today; + + // 查詢當天編號最大的單據 + $lastDoc = static::where('doc_no', 'like', $prefix . '%') + ->orderBy('doc_no', 'desc') + ->first(); + + if ($lastDoc) { + // 取得最後兩位序號並遞增 + $lastNumber = substr($lastDoc->doc_no, -2); + $nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT); + } else { + $nextNumber = '01'; + } + + $model->doc_no = $prefix . $nextNumber; + } + }); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function items(): HasMany + { + return $this->hasMany(InventoryCountItem::class, 'count_doc_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function completedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } +} diff --git a/app/Modules/Inventory/Models/InventoryCountItem.php b/app/Modules/Inventory/Models/InventoryCountItem.php new file mode 100644 index 0000000..480f56f --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryCountItem.php @@ -0,0 +1,38 @@ + 'decimal:2', + 'counted_qty' => 'decimal:2', + 'diff_qty' => 'decimal:2', + ]; + + public function doc(): BelongsTo + { + return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Modules/Inventory/Models/InventoryTransferItem.php b/app/Modules/Inventory/Models/InventoryTransferItem.php new file mode 100644 index 0000000..6b2386b --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryTransferItem.php @@ -0,0 +1,34 @@ + 'decimal:2', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Modules/Inventory/Models/InventoryTransferOrder.php b/app/Modules/Inventory/Models/InventoryTransferOrder.php new file mode 100644 index 0000000..8a4a63a --- /dev/null +++ b/app/Modules/Inventory/Models/InventoryTransferOrder.php @@ -0,0 +1,66 @@ + 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->doc_no)) { + $model->doc_no = 'TRF-' . date('YmdHis') . '-' . rand(100, 999); + } + }); + } + + public function fromWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'from_warehouse_id'); + } + + public function toWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'to_warehouse_id'); + } + + public function items(): HasMany + { + return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function postedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'posted_by'); + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 12f19d2..238c00a 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -8,6 +8,8 @@ use App\Modules\Inventory\Controllers\WarehouseController; use App\Modules\Inventory\Controllers\InventoryController; use App\Modules\Inventory\Controllers\SafetyStockController; use App\Modules\Inventory\Controllers\TransferOrderController; +use App\Modules\Inventory\Controllers\CountDocController; +use App\Modules\Inventory\Controllers\AdjustDocController; Route::middleware('auth')->group(function () { @@ -54,7 +56,7 @@ Route::middleware('auth')->group(function () { Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy'); }); - // API: 取得商品在特定倉庫的所有批號 + // API: 取得商品在特定倉庫的所有批號 Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches']) ->name('api.warehouses.inventory.batches'); }); @@ -70,9 +72,35 @@ Route::middleware('auth')->group(function () { }); }); - // 撥補單 (在庫存調撥時使用) + // 庫存盤點 (Stock Counting) - Global + Route::middleware('permission:inventory.view')->group(function () { + Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index'); + Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show'); + + Route::middleware('permission:inventory.adjust')->group(function () { + Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->name('inventory.count.store'); + Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->name('inventory.count.update'); + Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->name('inventory.count.destroy'); + }); + }); + + // 庫存盤調 (Stock Adjustment) - Global + Route::middleware('permission:inventory.adjust')->group(function () { + Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index'); + Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts'); + Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show'); + Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->name('inventory.adjust.store'); + Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->name('inventory.adjust.update'); + Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->name('inventory.adjust.destroy'); + }); + + // 撥補單/調撥單 (Transfer Order) - Global Route::middleware('permission:inventory.transfer')->group(function () { - Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store'); + Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index'); + Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show'); + Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->name('inventory.transfer.store'); + Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->name('inventory.transfer.update'); + Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->name('inventory.transfer.destroy'); }); Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories']) ->middleware('permission:inventory.view') diff --git a/app/Modules/Inventory/Services/AdjustService.php b/app/Modules/Inventory/Services/AdjustService.php new file mode 100644 index 0000000..7272eb8 --- /dev/null +++ b/app/Modules/Inventory/Services/AdjustService.php @@ -0,0 +1,153 @@ + $warehouseId, + 'count_doc_id' => $countDocId, + 'status' => 'draft', + 'reason' => $reason, + 'remarks' => $remarks, + 'created_by' => $userId, + ]); + } + + /** + * 從盤點單建立盤調單 + */ + public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc + { + return DB::transaction(function () use ($countDoc, $userId) { + // 1. 建立盤調單頭 + $adjDoc = $this->createDoc( + $countDoc->warehouse_id, + "盤點調整: " . $countDoc->doc_no, + "由盤點單 {$countDoc->doc_no} 自動生成", + $userId, + $countDoc->id + ); + + // 2. 抓取有差異的明細 (diff_qty != 0) + foreach ($countDoc->items as $item) { + if (abs($item->diff_qty) < 0.0001) continue; + + $adjDoc->items()->create([ + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + 'qty_before' => $item->system_qty, + 'adjust_qty' => $item->diff_qty, + 'notes' => "盤點差異: " . $item->diff_qty, + ]); + } + + return $adjDoc; + }); + } + + /** + * 更新調整單內容 (Items) + * 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠 + */ + public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void + { + DB::transaction(function () use ($doc, $itemsData) { + $doc->items()->delete(); + + foreach ($itemsData as $data) { + // 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準) + $inventory = Inventory::where('warehouse_id', $doc->warehouse_id) + ->where('product_id', $data['product_id']) + ->where('batch_number', $data['batch_number'] ?? null) + ->first(); + + $qtyBefore = $inventory ? $inventory->quantity : 0; + + $doc->items()->create([ + 'product_id' => $data['product_id'], + 'batch_number' => $data['batch_number'] ?? null, + 'qty_before' => $qtyBefore, + 'adjust_qty' => $data['adjust_qty'], + 'notes' => $data['notes'] ?? null, + ]); + } + }); + } + + /** + * 過帳 (Post) - 生效庫存異動 + */ + public function post(InventoryAdjustDoc $doc, int $userId): void + { + DB::transaction(function () use ($doc, $userId) { + foreach ($doc->items as $item) { + if ($item->adjust_qty == 0) continue; + + // 找尋或建立 Inventory + // 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管) + // 若是增加庫存,若不存在需建立 + + $inventory = Inventory::firstOrNew([ + 'warehouse_id' => $doc->warehouse_id, + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + ]); + + // 如果是新建立的 object (id 為空),需要初始化 default + if (!$inventory->exists) { + // 繼承 Product 成本或預設 0 (簡化處理) + $inventory->unit_cost = $item->product->cost ?? 0; + $inventory->quantity = 0; + } + + $oldQty = $inventory->quantity; + $newQty = $oldQty + $item->adjust_qty; + + $inventory->quantity = $newQty; + // 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權) + $inventory->total_value = $newQty * $inventory->unit_cost; + $inventory->save(); + + // 建立 Transaction + $inventory->transactions()->create([ + 'type' => '庫存調整', + 'quantity' => $item->adjust_qty, + 'unit_cost' => $inventory->unit_cost, + 'balance_before' => $oldQty, + 'balance_after' => $newQty, + 'reason' => "調整單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'), + 'actual_time' => now(), + 'user_id' => $userId, + ]); + } + + $doc->update([ + 'status' => 'posted', + 'posted_at' => now(), + 'posted_by' => $userId, + ]); + }); + } + + /** + * 作廢 (Void) + */ + public function void(InventoryAdjustDoc $doc, int $userId): void + { + if ($doc->status !== 'draft') { + throw new \Exception('只能作廢草稿狀態的單據'); + } + $doc->update([ + 'status' => 'voided', + 'updated_by' => $userId + ]); + } +} diff --git a/app/Modules/Inventory/Services/CountService.php b/app/Modules/Inventory/Services/CountService.php new file mode 100644 index 0000000..12b0d1e --- /dev/null +++ b/app/Modules/Inventory/Services/CountService.php @@ -0,0 +1,156 @@ + $warehouseId, + 'status' => 'draft', + 'remarks' => $remarks, + 'created_by' => $userId, + ]); + + return $doc; + }); + } + + /** + * 執行快照:鎖定當前庫存量 + */ + public function snapshot(InventoryCountDoc $doc): void + { + DB::transaction(function () use ($doc) { + // 清除舊的 items (如果有) + $doc->items()->delete(); + + // 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的) + // 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西 + $inventories = Inventory::where('warehouse_id', $doc->warehouse_id) + ->whereNull('deleted_at') + ->get(); + + $items = []; + foreach ($inventories as $inv) { + $items[] = [ + 'count_doc_id' => $doc->id, + 'product_id' => $inv->product_id, + 'batch_number' => $inv->batch_number, + 'system_qty' => $inv->quantity, + 'counted_qty' => null, // 預設未盤點 + 'diff_qty' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if (!empty($items)) { + InventoryCountItem::insert($items); + } + + $doc->update([ + 'status' => 'counting', + 'snapshot_date' => now(), + ]); + }); + } + + /** + * 完成盤點:過帳差異 + */ + public function complete(InventoryCountDoc $doc, int $userId): void + { + DB::transaction(function () use ($doc, $userId) { + foreach ($doc->items as $item) { + // 如果沒有輸入實盤數量,預設跳過或是視為 0? + // 安全起見:如果 counted_qty 是 null,表示沒盤到,跳過不處理 (或者依業務邏輯視為0) + // 這裡假設前端會確保有送出資料,若 null 則不做異動 + if (is_null($item->counted_qty)) { + continue; + } + + $diff = $item->counted_qty - $item->system_qty; + + // 如果無差異,更新 item 狀態即可 (diff_qty 已經是 computed field 或在儲存時計算) + // 這裡 update 一下 diff_qty 以防萬一 + $item->update(['diff_qty' => $diff]); + + if (abs($diff) > 0.0001) { + // 找回原本的 Inventory + $inventory = Inventory::where('warehouse_id', $doc->warehouse_id) + ->where('product_id', $item->product_id) + ->where('batch_number', $item->batch_number) + ->first(); + + if (!$inventory) { + // 如果原本沒庫存紀錄 (例如是新增的盤點項目),需要新建 Inventory + // 但目前 snapshot 邏輯只抓現有。若允許 "盤盈" (發現不在帳上的),需要額外邏輯 + // 暫時略過 "新增 Inventory" 的複雜邏輯,假設只能針對 existing batch 調整 + continue; + } + + $oldQty = $inventory->quantity; + $newQty = $oldQty + $diff; + + $inventory->quantity = $newQty; + $inventory->total_value = $inventory->unit_cost * $newQty; + $inventory->save(); + + // 寫入 Transaction + $inventory->transactions()->create([ + 'type' => '盤點調整', + 'quantity' => $diff, + 'unit_cost' => $inventory->unit_cost, + 'balance_before' => $oldQty, + 'balance_after' => $newQty, + 'reason' => "盤點單 {$doc->doc_no} 過帳", + 'actual_time' => now(), + 'user_id' => $userId, + ]); + } + } + + $doc->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'completed_by' => $userId, + ]); + }); + } + + /** + * 更新盤點數量 + */ + public function updateCount(InventoryCountDoc $doc, array $itemsData): void + { + DB::transaction(function () use ($doc, $itemsData) { + foreach ($itemsData as $data) { + $item = $doc->items()->find($data['id']); + if ($item) { + $countedQty = $data['counted_qty']; + $diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0; + + $item->update([ + 'counted_qty' => $countedQty, + 'diff_qty' => $diff, + 'notes' => $data['notes'] ?? $item->notes, + ]); + } + } + }); + } +} diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php new file mode 100644 index 0000000..336194f --- /dev/null +++ b/app/Modules/Inventory/Services/TransferService.php @@ -0,0 +1,152 @@ + $fromWarehouseId, + 'to_warehouse_id' => $toWarehouseId, + 'status' => 'draft', + 'remarks' => $remarks, + 'created_by' => $userId, + ]); + } + + /** + * 更新調撥單明細 + */ + public function updateItems(InventoryTransferOrder $order, array $itemsData): void + { + DB::transaction(function () use ($order, $itemsData) { + $order->items()->delete(); + + foreach ($itemsData as $data) { + $order->items()->create([ + 'product_id' => $data['product_id'], + 'batch_number' => $data['batch_number'] ?? null, + 'quantity' => $data['quantity'], + 'notes' => $data['notes'] ?? null, + ]); + } + }); + } + + /** + * 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的) + */ + public function post(InventoryTransferOrder $order, int $userId): void + { + DB::transaction(function () use ($order, $userId) { + $fromWarehouse = $order->fromWarehouse; + $toWarehouse = $order->toWarehouse; + + foreach ($order->items as $item) { + if ($item->quantity <= 0) continue; + + // 1. 處理來源倉 (扣除) + $sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id) + ->where('product_id', $item->product_id) + ->where('batch_number', $item->batch_number) + ->first(); + + if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { + throw ValidationException::withMessages([ + 'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"], + ]); + } + + $oldSourceQty = $sourceInventory->quantity; + $newSourceQty = $oldSourceQty - $item->quantity; + + $sourceInventory->quantity = $newSourceQty; + // 更新總值 (假設成本不變) + $sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; + $sourceInventory->save(); + + // 記錄來源交易 + $sourceInventory->transactions()->create([ + 'type' => '調撥出庫', + 'quantity' => -$item->quantity, + 'unit_cost' => $sourceInventory->unit_cost, + 'balance_before' => $oldSourceQty, + 'balance_after' => $newSourceQty, + 'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}", + 'actual_time' => now(), + 'user_id' => $userId, + ]); + + // 2. 處理目的倉 (增加) + $targetInventory = Inventory::firstOrCreate( + [ + 'warehouse_id' => $order->to_warehouse_id, + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + ], + [ + 'quantity' => 0, + 'unit_cost' => $sourceInventory->unit_cost, // 繼承成本 + 'total_value' => 0, + // 繼承其他屬性 + 'expiry_date' => $sourceInventory->expiry_date, + 'quality_status' => $sourceInventory->quality_status, + 'origin_country' => $sourceInventory->origin_country, + ] + ); + + // 若是新建立的,且成本為0,確保繼承成本 + if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) { + $targetInventory->unit_cost = $sourceInventory->unit_cost; + } + + $oldTargetQty = $targetInventory->quantity; + $newTargetQty = $oldTargetQty + $item->quantity; + + $targetInventory->quantity = $newTargetQty; + $targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; + $targetInventory->save(); + + // 記錄目的交易 + $targetInventory->transactions()->create([ + 'type' => '調撥入庫', + 'quantity' => $item->quantity, + 'unit_cost' => $targetInventory->unit_cost, + 'balance_before' => $oldTargetQty, + 'balance_after' => $newTargetQty, + 'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}", + 'actual_time' => now(), + 'user_id' => $userId, + ]); + } + + $order->update([ + 'status' => 'completed', + 'posted_at' => now(), + 'posted_by' => $userId, + ]); + }); + } + + public function void(InventoryTransferOrder $order, int $userId): void + { + if ($order->status !== 'draft') { + throw new \Exception('只能作廢草稿狀態的單據'); + } + $order->update([ + 'status' => 'voided', + 'updated_by' => $userId + ]); + } +} diff --git a/database/migrations/tenant/2026_01_28_143811_create_inventory_count_tables.php b/database/migrations/tenant/2026_01_28_143811_create_inventory_count_tables.php new file mode 100644 index 0000000..af2e91b --- /dev/null +++ b/database/migrations/tenant/2026_01_28_143811_create_inventory_count_tables.php @@ -0,0 +1,54 @@ +id(); + $table->string('doc_no')->unique(); // 單號 + $table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->string('status')->default('draft'); // draft, counting, completed, cancelled + $table->timestamp('snapshot_date')->nullable(); // 快照建立時間 + $table->timestamp('completed_at')->nullable(); // 完成時間 + $table->string('remarks')->nullable(); + + // 審核/建立資訊 + $table->foreignId('created_by')->constrained('users'); + $table->foreignId('updated_by')->nullable()->constrained('users'); + $table->foreignId('completed_by')->nullable()->constrained('users'); + + $table->timestamps(); + }); + + Schema::create('inventory_count_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('count_doc_id')->constrained('inventory_count_docs')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products'); + $table->string('batch_number')->nullable(); // 針對特定批號盤點 + + $table->decimal('system_qty', 10, 2)->default(0); // 系統帳面數量 (快照當下) + $table->decimal('counted_qty', 10, 2)->nullable(); // 實盤數量 + $table->decimal('diff_qty', 10, 2)->default(0); // 差異 (實盤 - 系統) + $table->string('notes')->nullable(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_count_items'); + Schema::dropIfExists('inventory_count_docs'); + } +}; diff --git a/database/migrations/tenant/2026_01_28_150608_create_inventory_adjust_tables.php b/database/migrations/tenant/2026_01_28_150608_create_inventory_adjust_tables.php new file mode 100644 index 0000000..3d70b63 --- /dev/null +++ b/database/migrations/tenant/2026_01_28_150608_create_inventory_adjust_tables.php @@ -0,0 +1,53 @@ +id(); + $table->string('doc_no')->unique(); // 單號 + $table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->string('status')->default('draft'); // draft, posted, voided + $table->string('reason')->nullable(); // 調整原因 (e.g. 報廢, 盤盈虧, 其他) + $table->string('remarks')->nullable(); + + // 審核/建立資訊 + $table->timestamp('posted_at')->nullable(); // 過帳時間 + $table->foreignId('created_by')->constrained('users'); + $table->foreignId('updated_by')->nullable()->constrained('users'); + $table->foreignId('posted_by')->nullable()->constrained('users'); + + $table->timestamps(); + }); + + Schema::create('inventory_adjust_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('adjust_doc_id')->constrained('inventory_adjust_docs')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products'); + $table->string('batch_number')->nullable(); + + // 記錄當下 "調整前" 的庫存與成本 (參考用) + $table->decimal('qty_before', 10, 2)->default(0); + + // 實際調整的數量 (可以正負, 正=增加, 負=減少) + $table->decimal('adjust_qty', 10, 2); + + $table->string('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_adjust_items'); + Schema::dropIfExists('inventory_adjust_docs'); + } +}; diff --git a/database/migrations/tenant/2026_01_28_151141_create_inventory_transfer_tables.php b/database/migrations/tenant/2026_01_28_151141_create_inventory_transfer_tables.php new file mode 100644 index 0000000..9014331 --- /dev/null +++ b/database/migrations/tenant/2026_01_28_151141_create_inventory_transfer_tables.php @@ -0,0 +1,47 @@ +id(); + $table->string('doc_no')->unique(); + $table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->string('status')->default('draft'); // draft, completed, voided + $table->string('remarks')->nullable(); + + // 審核/建立資訊 + $table->timestamp('posted_at')->nullable(); // 過帳時間 + $table->foreignId('created_by')->constrained('users'); + $table->foreignId('updated_by')->nullable()->constrained('users'); + $table->foreignId('posted_by')->nullable()->constrained('users'); + + $table->timestamps(); + }); + + Schema::create('inventory_transfer_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('transfer_order_id')->constrained('inventory_transfer_orders')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products'); + $table->string('batch_number')->nullable(); + $table->decimal('quantity', 10, 2); + $table->string('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_transfer_items'); + Schema::dropIfExists('inventory_transfer_orders'); + } +}; diff --git a/database/migrations/tenant/2026_01_28_173242_add_count_doc_id_to_inventory_adjust_docs.php b/database/migrations/tenant/2026_01_28_173242_add_count_doc_id_to_inventory_adjust_docs.php new file mode 100644 index 0000000..f0960da --- /dev/null +++ b/database/migrations/tenant/2026_01_28_173242_add_count_doc_id_to_inventory_adjust_docs.php @@ -0,0 +1,32 @@ +foreignId('count_doc_id') + ->after('doc_no') + ->nullable() + ->constrained('inventory_count_docs') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_adjust_docs', function (Blueprint $table) { + $table->dropConstrainedForeignId('count_doc_id'); + }); + } +}; diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 4cb3629..6036bbd 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -22,7 +22,8 @@ import { BarChart3, FileSpreadsheet, BookOpen, - ClipboardCheck + ClipboardCheck, + ArrowLeftRight } from "lucide-react"; import { toast, Toaster } from "sonner"; import { useState, useEffect, useMemo, useRef } from "react"; @@ -83,7 +84,7 @@ export default function AuthenticatedLayout({ id: "inventory-management", label: "商品與庫存管理", icon: , - permission: ["products.view", "warehouses.view"], // 滿足任一即可看到此群組 + permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組 children: [ { id: "product-management", @@ -99,6 +100,27 @@ export default function AuthenticatedLayout({ route: "/warehouses", permission: "warehouses.view", }, + { + id: "stock-counting", + label: "庫存盤點", + icon: , + route: "/inventory/count-docs", + permission: "inventory.view", + }, + { + id: "stock-adjustment", + label: "庫存盤調", + icon: , + route: "/inventory/adjust-docs", + permission: "inventory.adjust", + }, + { + id: "stock-transfer", + label: "庫存調撥", + icon: , + route: "/inventory/transfer-orders", + permission: "inventory.transfer", + }, ], }, { @@ -260,7 +282,11 @@ export default function AuthenticatedLayout({ const activeItem = menuItems.find(item => item.children?.some(child => child.route && url.startsWith(child.route)) ); - return activeItem ? [activeItem.id] : ["inventory-management"]; + const defaultExpanded = ["inventory-management"]; + if (activeItem && !defaultExpanded.includes(activeItem.id)) { + defaultExpanded.push(activeItem.id); + } + return defaultExpanded; }); // 監聽 URL 變化,確保「當前」頁面所屬群組是展開的 diff --git a/resources/js/Pages/Inventory/Adjust/Index.tsx b/resources/js/Pages/Inventory/Adjust/Index.tsx new file mode 100644 index 0000000..ffb25a0 --- /dev/null +++ b/resources/js/Pages/Inventory/Adjust/Index.tsx @@ -0,0 +1,409 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, useForm, router } from '@inertiajs/react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Badge } from "@/Components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/Components/ui/dialog"; +import { Label } from "@/Components/ui/label"; +import { Plus, Search, X, Eye, Pencil, ClipboardCheck } from "lucide-react"; +import { useState, useCallback, useEffect } from 'react'; +import Pagination from '@/Components/shared/Pagination'; +import { SearchableSelect } from '@/Components/ui/searchable-select'; +import { Can } from '@/Components/Permission/Can'; +import debounce from 'lodash/debounce'; +import axios from 'axios'; + +interface Doc { + id: string; + doc_no: string; + warehouse_name: string; + reason: string; + status: string; + created_by: string; + created_at: string; + posted_at: string; +} + +interface Warehouse { + id: string; + name: string; +} + +interface Filters { + search?: string; + warehouse_id?: string; + per_page?: string; +} + +interface DocsPagination { + data: Doc[]; + current_page: number; + per_page: number; + total: number; + links: any[]; // Adjust type as needed for pagination links +} + +export default function Index({ docs, warehouses, filters }: { docs: DocsPagination, warehouses: Warehouse[], filters: Filters }) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(filters.search || ''); + const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || ''); + const [perPage, setPerPage] = useState(filters.per_page || '15'); + + // For Count Doc Selection + const [pendingCounts, setPendingCounts] = useState([]); + const [loadingPending, setLoadingPending] = useState(false); + const [scanSearch, setScanSearch] = useState(''); + + const fetchPendingCounts = useCallback( + debounce((search = '') => { + setLoadingPending(true); + axios.get(route('inventory.adjust.pending-counts'), { params: { search } }) + .then(res => setPendingCounts(res.data)) + .finally(() => setLoadingPending(false)); + }, 300), + [] + ); + + useEffect(() => { + if (isDialogOpen) { + fetchPendingCounts(); + } + }, [isDialogOpen, fetchPendingCounts]); + + const debouncedFilter = useCallback( + debounce((params: Filters) => { + router.get(route('inventory.adjust.index'), params as any, { + preserveState: true, + replace: true, + }); + }, 300), + [] + ); + + const handleSearchChange = (val: string) => { + setSearchQuery(val); + debouncedFilter({ search: val, warehouse_id: warehouseId, per_page: perPage }); + }; + + const handleWarehouseChange = (val: string) => { + setWarehouseId(val); + debouncedFilter({ search: searchQuery, warehouse_id: val, per_page: perPage }); + }; + + const handlePerPageChange = (val: string) => { + setPerPage(val); + debouncedFilter({ search: searchQuery, warehouse_id: warehouseId, per_page: val }); + }; + + const { data, setData, post, processing, reset } = useForm({ + count_doc_id: null as string | null, + warehouse_id: '', + reason: '', + remarks: '', + }); + + const handleCreate = (countDocId?: string) => { + if (countDocId) { + setData('count_doc_id', countDocId); + router.post(route('inventory.adjust.store'), { count_doc_id: countDocId }, { + onSuccess: () => setIsDialogOpen(false), + }); + return; + } + + post(route('inventory.adjust.store'), { + onSuccess: () => { + setIsDialogOpen(false); + reset(); + }, + }); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'draft': + return 草稿; + case 'posted': + return 已過帳; + case 'voided': + return 已作廢; + default: + return {status}; + } + }; + + return ( + + + +
+
+
+

+ + 庫存盤調單 +

+

針對盤點差異進行庫存調整與過帳 (盤盈、盤虧、報廢等)

+
+
+ + {/* Toolbar Context */} +
+
+ {/* Search */} +
+ + handleSearchChange(e.target.value)} + className="pl-10 pr-10 h-9" + /> + {searchQuery && ( + + )} +
+ + {/* Warehouse Filter */} + ({ value: w.id, label: w.name })) + ]} + value={warehouseId} + onValueChange={handleWarehouseChange} + placeholder="選擇倉庫" + className="w-full md:w-[200px] h-9" + /> + + {/* Action Buttons */} +
+ + + +
+
+
+ + {/* Table Container */} +
+ + + + # + 單號 + 倉庫 + 調整原因 + 狀態 + 建立者 + 建立時間 + 過帳時間 + 操作 + + + + {docs.data.length === 0 ? ( + + + 尚無任何盤調單據 + + + ) : ( + docs.data.map((doc: Doc, index: number) => ( + router.visit(route('inventory.adjust.show', [doc.id]))} + > + + {(docs.current_page - 1) * docs.per_page + index + 1} + + + {doc.doc_no} + + {doc.warehouse_name} + {doc.reason} + {getStatusBadge(doc.status)} + {doc.created_by} + {doc.created_at} + {doc.posted_at} + + + + + )) + )} + +
+
+ +
+
+
+ 每頁顯示 + + +
+ 共 {docs?.total || 0} 筆紀錄 +
+ +
+
+ + {/* Create Dialog */} + + + + + + 新增盤調單 + + + 請選擇一個已完成的盤點單來生成盤調項目,或手動建立。 + + + +
+ {/* Option 1: Scan/Select from Count Docs */} +
+ +
+ + { + setScanSearch(e.target.value); + fetchPendingCounts(e.target.value); + }} + /> +
+ +
+ {loadingPending ? ( +
載入中...
+ ) : pendingCounts.length === 0 ? ( +
+ 查無可供盤調的盤點單 (需為已完成狀態) +
+ ) : ( +
+ {pendingCounts.map((c: any) => ( +
handleCreate(c.id)} + > +
+

{c.doc_no}

+

{c.warehouse_name} | 完成於: {c.completed_at}

+
+ +
+ ))} +
+ )} +
+
+ +
+
+
+
+ + {/* Option 2: Manual (Optional, though less common in this flow) */} +
+ +
+
+ + ({ value: w.id, label: w.name }))} + value={data.warehouse_id} + onValueChange={(val) => setData('warehouse_id', val)} + placeholder="選擇倉庫" + /> +
+
+ + setData('reason', e.target.value)} + className="h-10" + /> +
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Adjust/Show.tsx b/resources/js/Pages/Inventory/Adjust/Show.tsx new file mode 100644 index 0000000..3a86fe2 --- /dev/null +++ b/resources/js/Pages/Inventory/Adjust/Show.tsx @@ -0,0 +1,511 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, Link, useForm, router } from '@inertiajs/react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Badge } from "@/Components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/Components/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/Components/ui/alert-dialog"; +import { Label } from "@/Components/ui/label"; +import { Textarea } from "@/Components/ui/textarea"; +import { Save, CheckCircle, Trash2, ArrowLeft, Plus, X, Search, FileText } from "lucide-react"; +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/Components/ui/dialog"; +import axios from 'axios'; +import { Can } from '@/Components/Permission/Can'; + +interface AdjItem { + id?: string; + product_id: string; + product_name: string; + product_code: string; + batch_number: string | null; + unit: string; + qty_before: number; + adjust_qty: number | string; + notes: string; +} + +interface AdjDoc { + id: string; + doc_no: string; + warehouse_id: string; + warehouse_name: string; + status: string; + reason: string; + remarks: string; + created_at: string; + created_by: string; + count_doc_id?: string; + count_doc_no?: string; + items: AdjItem[]; +} + +export default function Show({ doc }: { auth: any, doc: AdjDoc }) { + const isDraft = doc.status === 'draft'; + + // Main Form using Inertia useForm + const { data, setData, put, delete: destroy, processing } = useForm({ + reason: doc.reason, + remarks: doc.remarks || '', + items: doc.items || [], + action: 'save', + }); + + const [newItemOpen, setNewItemOpen] = useState(false); + + // Helper to add new item + const addItem = (product: any, batchNumber: string | null) => { + // Check if exists + const exists = data.items.find(i => + i.product_id === String(product.id) && + i.batch_number === batchNumber + ); + + if (exists) { + alert('此商品與批號已在列表中'); + return; + } + + setData('items', [ + ...data.items, + { + product_id: String(product.id), + product_name: product.name, + product_code: product.code, + unit: product.unit, + batch_number: batchNumber, + qty_before: product.qty || 0, // Not fetched dynamically for now, or could fetch via API + adjust_qty: 0, + notes: '', + } + ]); + setNewItemOpen(false); + }; + + const removeItem = (index: number) => { + const newItems = [...data.items]; + newItems.splice(index, 1); + setData('items', newItems); + }; + + const updateItem = (index: number, field: keyof AdjItem, value: any) => { + const newItems = [...data.items]; + (newItems[index] as any)[field] = value; + setData('items', newItems); + }; + + const handleSave = () => { + setData('action', 'save'); + put(route('inventory.adjust.update', [doc.id]), { + preserveScroll: true, + }); + }; + + const handlePost = () => { + // Validate + if (data.items.length === 0) { + alert('請至少加入一個調整項目'); + return; + } + + const hasZero = data.items.some(i => Number(i.adjust_qty) === 0); + if (hasZero && !confirm('部分項目的調整數量為 0,確定要繼續嗎?')) { + return; + } + + if (confirm('確定要過帳嗎?過帳後將無法修改,並直接影響庫存。')) { + router.visit(route('inventory.adjust.update', [doc.id]), { + method: 'put', + data: { ...data, action: 'post' } as any, + }); + } + }; + + const handleDelete = () => { + destroy(route('inventory.adjust.destroy', [doc.id])); + }; + + return ( + + + +
+
+ +
+
+ +
+

+ {doc.doc_no} + {isDraft ? ( + 草稿 + ) : ( + 已過帳 + )} +

+
+ 倉庫: {doc.warehouse_name} + | + 建立者: {doc.created_by} + | + 時間: {doc.created_at} +
+
+
+
+ {isDraft && ( + + + + + + + + 確定要刪除此盤調單嗎? + + 此動作將會永久移除本張草稿,且無法復原。 + + + + 取消 + 確認刪除 + + + + + + + + + )} +
+
+ +
+ + + 明細備註 + + +
+ + {isDraft ? ( + setData('reason', e.target.value)} + className="focus:ring-primary-main" + /> + ) : ( +
{data.reason}
+ )} +
+
+ + {isDraft ? ( +