with(['company']) ->withCount('stocks as products_count') ->withSum('stocks as total_stock', 'quantity'); if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('address', 'like', "%{$search}%"); }); } if ($request->filled('type')) { $query->where('type', $request->input('type')); } $warehouses = $query->orderBy('type')->orderBy('name') ->paginate($request->input('per_page', 10)) ->withQueryString(); $stats = [ 'total' => Warehouse::count(), 'main' => Warehouse::main()->count(), 'branch' => Warehouse::branch()->count(), 'low_stock' => WarehouseStock::lowStock() ->whereHas('warehouse', fn($q) => $q)->count(), ]; $companies = collect(); if (Auth::user()->isSystemAdmin()) { $companies = \App\Models\System\Company::active()->orderBy('name')->get(); } return view('admin.warehouses.index', compact('warehouses', 'stats', 'companies')); } public function store(Request $request) { $validated = $request->validate([ 'company_id' => Auth::user()->isSystemAdmin() ? 'nullable|exists:companies,id' : 'nullable', 'name' => 'required|string|max:255', 'type' => 'required|in:main,branch', 'address' => 'nullable|string|max:500', ], [], [ 'name' => __('Warehouse Name'), 'type' => __('Warehouse Type'), 'company_id' => __('Company Name'), ]); if (!Auth::user()->isSystemAdmin()) { $validated['company_id'] = Auth::user()->company_id; } Warehouse::create($validated); if ($request->ajax() || $request->wantsJson()) { return response()->json([ 'success' => true, 'message' => __('Warehouse created successfully'), ]); } return redirect()->route('admin.warehouses.index') ->with('success', __('Warehouse created successfully')); } public function update(Request $request, Warehouse $warehouse) { $rules = [ 'name' => 'required|string|max:255', 'type' => 'required|in:main,branch', 'address' => 'nullable|string|max:500', 'is_active' => 'required|boolean', ]; if (Auth::user()->isSystemAdmin()) { $rules['company_id'] = 'nullable|exists:companies,id'; } $validated = $request->validate($rules, [], [ 'name' => __('Warehouse Name'), 'type' => __('Warehouse Type'), 'company_id' => __('Company Name'), ]); $warehouse->update($validated); if ($request->ajax() || $request->wantsJson()) { return response()->json([ 'success' => true, 'message' => __('Warehouse updated successfully'), ]); } return redirect()->route('admin.warehouses.index') ->with('success', __('Warehouse updated successfully')); } public function destroy(Warehouse $warehouse) { if ($warehouse->stocks()->where('quantity', '>', 0)->exists()) { return redirect()->route('admin.warehouses.index') ->with('error', __('Cannot delete warehouse with existing stock')); } $warehouse->delete(); return redirect()->route('admin.warehouses.index') ->with('success', __('Warehouse deleted successfully')); } public function toggleStatus(Warehouse $warehouse) { $warehouse->update(['is_active' => !$warehouse->is_active]); $status = $warehouse->is_active ? __('enabled') : __('disabled'); return redirect()->route('admin.warehouses.index') ->with('success', __('Warehouse :status successfully', ['status' => $status])); } // ─── 模組 2:庫存管理 ─── public function inventory(Request $request) { $tab = $request->input('tab', 'stock'); $warehouses = Warehouse::active()->orderBy('name')->get(); $data = compact('warehouses', 'tab'); if ($tab === 'stock') { $inventory_warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name', 'type']); $query = Product::with(['stocks' => function($q) { $q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock'); }, 'translations']); if ($request->filled('warehouse_id')) { $query->whereHas('stocks', fn($q) => $q->where('warehouse_id', $request->input('warehouse_id'))); } if ($search = $request->input('search')) { $query->where('name', 'like', "%{$search}%"); } $data['inventory_warehouses'] = $inventory_warehouses; $data['products'] = $query->orderBy('name') ->paginate($request->input('per_page', 15)) ->withQueryString(); } elseif ($tab === 'stock-in') { $query = StockInOrder::with(['warehouse:id,name', 'creator:id,name']) ->latest(); if ($request->filled('status')) { $query->where('status', $request->input('status')); } $data['orders'] = $query->paginate($request->input('per_page', 10))->withQueryString(); $data['products'] = Product::orderBy('name')->get(['id', 'name', 'image_url']); } elseif ($tab === 'movements') { $query = StockMovement::with(['warehouse:id,name', 'product:id,name', 'creator:id,name']) ->orderBy('created_at', 'desc'); if ($request->filled('warehouse_id')) { $query->where('warehouse_id', $request->input('warehouse_id')); } if ($request->filled('type')) { $query->where('type', $request->input('type')); } $data['movements'] = $query->paginate($request->input('per_page', 15))->withQueryString(); } if ($request->ajax()) { return response()->json([ 'success' => true, 'tab' => $tab, 'html' => view('admin.warehouses.partials.tab-' . $tab, $data)->render() ]); } return view('admin.warehouses.inventory', $data); } /** * 建立入庫單 */ public function storeStockIn(Request $request) { $validated = $request->validate([ 'warehouse_id' => 'required|exists:warehouses,id', 'note' => 'nullable|string|max:500', 'items' => 'required|array|min:1', 'items.*.product_id' => 'required|exists:products,id', 'items.*.quantity' => 'required|integer|min:1', ]); DB::transaction(function () use ($validated) { $order = StockInOrder::create([ 'company_id' => Auth::user()->company_id, 'warehouse_id' => $validated['warehouse_id'], 'order_no' => 'SI-' . now()->format('Ymd') . '-' . str_pad(StockInOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'status' => 'draft', 'note' => $validated['note'] ?? null, 'created_by' => Auth::id(), ]); foreach ($validated['items'] as $item) { $order->items()->create($item); } }); if ($request->ajax()) { return response()->json([ 'success' => true, 'message' => __('Stock-in order created') ]); } return redirect()->route('admin.warehouses.inventory', ['tab' => 'stock-in']) ->with('success', __('Stock-in order created')); } /** * 確認入庫單(草稿 → 完成,同時增加庫存) */ public function confirmStockIn(StockInOrder $stockInOrder) { if ($stockInOrder->status !== 'draft') { return back()->with('error', __('Order already processed')); } DB::transaction(function () use ($stockInOrder) { $stockInOrder->update([ 'status' => 'completed', 'completed_at' => now(), ]); foreach ($stockInOrder->items as $item) { // 更新或建立倉庫庫存 $stock = WarehouseStock::firstOrCreate( ['warehouse_id' => $stockInOrder->warehouse_id, 'product_id' => $item->product_id], ['quantity' => 0, 'safety_stock' => 0] ); $before = $stock->quantity; $stock->increment('quantity', $item->quantity); // 記錄異動 StockMovement::create([ 'company_id' => $stockInOrder->company_id, 'warehouse_id' => $stockInOrder->warehouse_id, 'product_id' => $item->product_id, 'type' => 'in', 'quantity' => $item->quantity, 'before_qty' => $before, 'after_qty' => $before + $item->quantity, 'reference_type' => StockInOrder::class, 'reference_id' => $stockInOrder->id, 'note' => __('Stock-in order') . ' #' . $stockInOrder->order_no, 'created_by' => Auth::id(), 'created_at' => now(), ]); } }); return back()->with('success', __('Stock-in order confirmed and inventory updated')); } // ─── 模組 3:調撥單 ─── public function transfers(Request $request) { $query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name', 'items']) ->latest(); if ($request->filled('type')) { $query->where('type', $request->input('type')); } if ($request->filled('status')) { $query->where('status', $request->input('status')); } $orders = $query->paginate(10)->withQueryString(); $warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name', 'type']); $machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']); $products = Product::orderBy('name')->get(['id', 'name', 'image_url']); return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products')); } /** * 建立調撥單(W2W 或 M2W) */ public function storeTransfer(Request $request) { $validated = $request->validate([ 'type' => 'required|in:warehouse_to_warehouse,machine_to_warehouse', 'from_warehouse_id' => 'required_if:type,warehouse_to_warehouse|nullable|exists:warehouses,id', 'from_machine_id' => 'required_if:type,machine_to_warehouse|nullable|exists:machines,id', 'to_warehouse_id' => 'required|exists:warehouses,id', 'note' => 'nullable|string|max:500', 'items' => 'required|array|min:1', 'items.*.product_id' => 'required|exists:products,id', 'items.*.quantity' => 'required|integer|min:1', ]); DB::transaction(function () use ($validated) { $order = TransferOrder::create([ 'company_id' => Auth::user()->company_id, 'order_no' => 'TR-' . now()->format('Ymd') . '-' . str_pad(TransferOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'type' => $validated['type'], 'from_warehouse_id' => $validated['from_warehouse_id'] ?? null, 'from_machine_id' => $validated['from_machine_id'] ?? null, 'to_warehouse_id' => $validated['to_warehouse_id'], 'status' => TransferOrder::STATUS_DRAFT, 'note' => $validated['note'] ?? null, 'created_by' => Auth::id(), ]); foreach ($validated['items'] as $item) { $order->items()->create($item); } }); if ($request->ajax()) { return response()->json([ 'success' => true, 'message' => __('Transfer order created') ]); } return redirect()->route('admin.warehouses.transfers') ->with('success', __('Transfer order created')); } /** * 確認調撥(扣除來源庫存 + 增加目標庫存) */ public function confirmTransfer(TransferOrder $transferOrder) { if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) { return back()->with('error', __('Order already processed')); } DB::transaction(function () use ($transferOrder) { $transferOrder->loadMissing('items'); foreach ($transferOrder->items as $item) { // 扣除來源庫存(僅 W2W) if ($transferOrder->type === TransferOrder::TYPE_W2W && $transferOrder->from_warehouse_id) { $fromStock = WarehouseStock::where('warehouse_id', $transferOrder->from_warehouse_id) ->where('product_id', $item->product_id)->first(); if (!$fromStock || $fromStock->quantity < $item->quantity) { throw new \Exception(__('Insufficient stock for transfer')); } $beforeFrom = $fromStock->quantity; $fromStock->decrement('quantity', $item->quantity); StockMovement::create([ 'company_id' => $transferOrder->company_id, 'warehouse_id' => $transferOrder->from_warehouse_id, 'product_id' => $item->product_id, 'type' => 'transfer_out', 'quantity' => $item->quantity, 'before_qty' => $beforeFrom, 'after_qty' => $beforeFrom - $item->quantity, 'reference_type' => TransferOrder::class, 'reference_id' => $transferOrder->id, 'note' => __('Transfer out') . ' #' . $transferOrder->order_no, 'created_by' => Auth::id(), 'created_at' => now(), ]); } // 增加目標倉庫庫存 $toStock = WarehouseStock::firstOrCreate( ['warehouse_id' => $transferOrder->to_warehouse_id, 'product_id' => $item->product_id], ['quantity' => 0, 'safety_stock' => 0] ); $beforeTo = $toStock->quantity; $toStock->increment('quantity', $item->quantity); StockMovement::create([ 'company_id' => $transferOrder->company_id, 'warehouse_id' => $transferOrder->to_warehouse_id, 'product_id' => $item->product_id, 'type' => 'transfer_in', 'quantity' => $item->quantity, 'before_qty' => $beforeTo, 'after_qty' => $beforeTo + $item->quantity, 'reference_type' => TransferOrder::class, 'reference_id' => $transferOrder->id, 'note' => __('Transfer in') . ' #' . $transferOrder->order_no, 'created_by' => Auth::id(), 'created_at' => now(), ]); } $transferOrder->update([ 'status' => TransferOrder::STATUS_COMPLETED, 'completed_at' => now(), ]); }); return back()->with('success', __('Transfer completed')); } // ─── 模組 4:機台庫存總覽 ─── public function machineInventory(Request $request) { $query = Machine::with(['slots' => fn($q) => $q->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')]) ->withCount('slots as total_slots') ->withSum('slots as total_stock', 'stock'); if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('serial_no', 'like', "%{$search}%"); }); } $machines = $query->orderBy('name') ->paginate($request->input('per_page', 10)) ->withQueryString(); return view('admin.warehouses.machine-inventory', compact('machines')); } /** * AJAX:取得單台機台貨道詳情 */ public function machineSlots(Machine $machine) { $slots = $machine->slots() ->with('product:id,name,image_url') ->orderByRaw('CAST(slot_no AS UNSIGNED) ASC') ->get(); return response()->json(['success' => true, 'slots' => $slots]); } // ─── 模組 5:機台補貨 ─── public function replenishments(Request $request) { $query = ReplenishmentOrder::with(['warehouse:id,name', 'machine:id,name,serial_no', 'assignee:id,name', 'creator:id,name']) ->withCount('items') ->latest(); if ($request->filled('status')) { $query->where('status', $request->input('status')); } $orders = $query->paginate(10)->withQueryString(); $warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']); $machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']); return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines')); } /** * 建立補貨單 */ public function storeReplenishment(Request $request) { $validated = $request->validate([ 'warehouse_id' => 'required|exists:warehouses,id', 'machine_id' => 'required|exists:machines,id', 'note' => 'nullable|string|max:500', 'items' => 'required|array|min:1', 'items.*.product_id' => 'required|exists:products,id', 'items.*.slot_no' => 'required|integer', 'items.*.quantity' => 'required|integer|min:1', ]); DB::transaction(function () use ($validated) { $order = ReplenishmentOrder::create([ 'company_id' => Auth::user()->company_id, 'order_no' => 'RP-' . now()->format('Ymd') . '-' . str_pad(ReplenishmentOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'warehouse_id' => $validated['warehouse_id'], 'machine_id' => $validated['machine_id'], 'status' => ReplenishmentOrder::STATUS_PENDING, 'note' => $validated['note'] ?? null, 'created_by' => Auth::id(), ]); foreach ($validated['items'] as $item) { $order->items()->create($item); } }); if ($request->ajax()) { return response()->json([ 'success' => true, 'message' => __('Replenishment order created') ]); } return redirect()->route('admin.warehouses.replenishments') ->with('success', __('Replenishment order created')); } /** * 確認補貨完成(扣倉庫庫存 + 增加機台貨道庫存) */ public function confirmReplenishment(ReplenishmentOrder $replenishmentOrder) { if ($replenishmentOrder->status === ReplenishmentOrder::STATUS_COMPLETED) { return back()->with('error', __('Order already completed')); } DB::transaction(function () use ($replenishmentOrder) { $replenishmentOrder->loadMissing('items'); foreach ($replenishmentOrder->items as $item) { // 扣除倉庫庫存 $stock = WarehouseStock::where('warehouse_id', $replenishmentOrder->warehouse_id) ->where('product_id', $item->product_id)->first(); if ($stock && $stock->quantity >= $item->quantity) { $before = $stock->quantity; $stock->decrement('quantity', $item->quantity); StockMovement::create([ 'company_id' => $replenishmentOrder->company_id, 'warehouse_id' => $replenishmentOrder->warehouse_id, 'product_id' => $item->product_id, 'type' => 'out', 'quantity' => $item->quantity, 'before_qty' => $before, 'after_qty' => $before - $item->quantity, 'reference_type' => ReplenishmentOrder::class, 'reference_id' => $replenishmentOrder->id, 'note' => __('Replenishment') . ' #' . $replenishmentOrder->order_no, 'created_by' => Auth::id(), 'created_at' => now(), ]); } // 增加機台貨道庫存 MachineSlot::where('machine_id', $replenishmentOrder->machine_id) ->where('slot_no', $item->slot_no) ->increment('stock', $item->quantity); } $replenishmentOrder->update([ 'status' => ReplenishmentOrder::STATUS_COMPLETED, 'completed_at' => now(), ]); }); return back()->with('success', __('Replenishment completed')); } /** * AJAX:取得來源庫存(倉庫或機台) */ public function getStockAjax(Request $request) { $warehouseId = $request->input('warehouse_id'); $machineId = $request->input('machine_id'); try { if ($warehouseId) { // Warehouse uses TenantScoped, findOrFail will respect it $warehouse = Warehouse::findOrFail($warehouseId); $stocks = WarehouseStock::with('product:id,name,image_url') ->where('warehouse_id', $warehouse->id) ->where('quantity', '>', 0) ->get(); $data = $stocks->map(fn($s) => [ 'id' => $s->product_id, 'name' => $s->product?->name ?? 'Unknown', 'quantity' => $s->quantity, 'image' => $s->product?->image_url ]); } elseif ($machineId) { // Machine uses TenantScoped and machine_access scope $machine = Machine::findOrFail($machineId); $slots = MachineSlot::with('product:id,name,image_url') ->where('machine_id', $machine->id) ->where('stock', '>', 0) ->get() ->groupBy('product_id'); $data = $slots->map(function($group) { $p = $group->first()->product; return [ 'id' => $group->first()->product_id, 'name' => $p?->name ?? 'Unknown', 'quantity' => $group->sum('stock'), 'image' => $p?->image_url ]; })->values(); } else { return response()->json(['success' => false, 'message' => 'No source provided']); } } catch (\Exception $e) { return response()->json(['success' => false, 'message' => __('Unauthorized or not found')]); } return response()->json(['success' => true, 'data' => $data]); } /** * AJAX:取得倉庫商品庫存詳情 (用於滑出面板) */ public function warehouseStocks(Warehouse $warehouse, Request $request) { $query = $warehouse->stocks()->with('product:id,name,image_url'); if ($search = $request->input('search')) { $query->whereHas('product', fn($q) => $q->where('name', 'like', "%{$search}%")); } $stocks = $query->get(); return response()->json([ 'success' => true, 'warehouse' => [ 'id' => $warehouse->id, 'name' => $warehouse->name, 'type' => $warehouse->type, ], 'stocks' => $stocks->map(fn($s) => [ 'id' => $s->id, 'product_id' => $s->product_id, 'product_name' => $s->product?->name ?? 'Unknown', 'image_url' => $s->product?->image_url, 'quantity' => (int)$s->quantity, 'safety_stock' => (int)$s->safety_stock, ]) ]); } /** * AJAX:取得進貨單詳情 */ public function stockInOrderDetails(StockInOrder $order) { $order->load(['warehouse', 'creator', 'items.product']); return response()->json([ 'success' => true, 'order' => [ 'id' => $order->id, 'order_no' => $order->order_no, 'status' => $order->status, 'warehouse_name' => $order->warehouse?->name, 'creator_name' => $order->creator?->name, 'created_at' => $order->created_at?->format('Y-m-d H:i:s'), 'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'), 'note' => $order->note, ], 'items' => $order->items->map(fn($item) => [ 'product_name' => $item->product?->name ?? 'Unknown', 'image_url' => $item->product?->image_url, 'quantity' => $item->quantity, ]) ]); } /** * Get replenishment order details for AJAX. */ public function replenishmentOrderDetails(Request $request, $id) { $order = \App\Models\WarehouseReplenishment::with(['machine', 'warehouse', 'creator']) ->where('id', $id) ->firstOrFail(); $items = \App\Models\WarehouseReplenishmentItem::with('product') ->where('replenishment_id', $id) ->get() ->map(function($item) { return [ 'product_id' => $item->product_id, 'product_name' => $item->product?->name ?? 'Unknown', 'image_url' => $item->product?->image_url, 'quantity' => $item->quantity, 'slot_no' => $item->slot_no, ]; }); return response()->json([ 'success' => true, 'order' => [ 'id' => $order->id, 'order_no' => $order->order_no, 'status' => $order->status, 'machine_name' => $order->machine?->name, 'warehouse_name' => $order->warehouse?->name, 'creator_name' => $order->creator?->name, 'note' => $order->note, 'created_at' => $order->created_at->format('Y-m-d H:i:s'), 'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'), ], 'items' => $items ]); } /** * Get transfer order details for AJAX. */ public function transferOrderDetails(Request $request, $id) { $order = \App\Models\WarehouseTransfer::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator']) ->where('id', $id) ->firstOrFail(); $items = \App\Models\WarehouseTransferItem::with('product') ->where('transfer_id', $id) ->get() ->map(function($item) { return [ 'product_id' => $item->product_id, 'product_name' => $item->product?->name ?? 'Unknown', 'image_url' => $item->product?->image_url, 'quantity' => $item->quantity, ]; }); return response()->json([ 'success' => true, 'order' => [ 'id' => $order->id, 'order_no' => $order->order_no, 'status' => $order->status, 'type' => $order->type, 'from_name' => $order->type === 'warehouse_to_warehouse' ? $order->fromWarehouse?->name : $order->fromMachine?->name, 'to_warehouse_name' => $order->toWarehouse?->name, 'creator_name' => $order->creator?->name, 'note' => $order->note, 'created_at' => $order->created_at->format('Y-m-d H:i:s'), 'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'), ], 'items' => $items ]); } }