transferService = $transferService; } /** * 建立叫貨單(含明細) */ public function create(array $data, array $items, int $userId, bool $submitImmediately = false): StoreRequisition { return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) { $requisition = new StoreRequisition([ 'store_warehouse_id' => $data['store_warehouse_id'], 'status' => $submitImmediately ? 'pending' : 'draft', 'submitted_at' => $submitImmediately ? now() : null, 'remark' => $data['remark'] ?? null, 'created_by' => $userId, ]); // 手動產生單號,因為 saveQuietly 會繞過模型事件 if (empty($requisition->doc_no)) { $today = date('Ymd'); $prefix = 'SR-' . $today . '-'; $lastDoc = StoreRequisition::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'; } $requisition->doc_no = $prefix . $nextNumber; } // 靜默建立以抑制自動日誌 $requisition->saveQuietly(); $itemsToInsert = []; $productIds = collect($items)->pluck('product_id')->unique()->toArray(); $products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id'); $diff = ['added' => [], 'removed' => [], 'updated' => []]; foreach ($items as $item) { $itemsToInsert[] = [ 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, 'created_at' => now(), 'updated_at' => now(), ]; $product = $products->get($item['product_id']); $diff['added'][] = [ 'product_name' => $product?->name ?? '未知商品', 'new' => [ 'quantity' => (float)$item['requested_qty'], 'remark' => $item['remark'] ?? null, ] ]; } StoreRequisitionItem::insert($itemsToInsert); // 如果需直接提交,觸發通知 if ($submitImmediately) { $this->notifyApprovers($requisition, 'submitted', $userId); } // 手動發送高品質日誌 activity() ->performedOn($requisition) ->causedBy($userId) ->event('created') ->withProperties([ 'items_diff' => $diff, 'attributes' => [ 'doc_no' => $requisition->doc_no, 'store_warehouse_id' => $requisition->store_warehouse_id, 'status' => $requisition->status, 'remark' => $requisition->remark, 'created_by' => $requisition->created_by, 'submitted_at' => $requisition->submitted_at, ] ]) ->log('created'); return $requisition->load('items'); }); } /** * 更新叫貨單(僅限 draft / rejected 狀態) */ public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition { if (!in_array($requisition->status, ['draft', 'rejected'])) { throw ValidationException::withMessages([ 'status' => '僅能編輯草稿或被駁回的叫貨單', ]); } return DB::transaction(function () use ($requisition, $data, $items) { // 擷取舊狀態供日誌對照 $oldAttributes = [ 'store_warehouse_id' => $requisition->store_warehouse_id, 'remark' => $requisition->remark, ]; // 手動更新屬性 $requisition->store_warehouse_id = $data['store_warehouse_id']; $requisition->remark = $data['remark'] ?? null; $requisition->reject_reason = null; // 清除駁回原因 // 品項對比邏輯 $oldItems = $requisition->items()->with('product:id,name')->get(); $oldItemsMap = $oldItems->keyBy('product_id'); $newItemsMap = collect($items)->keyBy('product_id'); $diff = [ 'added' => [], 'removed' => [], 'updated' => [], ]; // 1. 處理更新與新增 foreach ($items as $itemData) { $productId = $itemData['product_id']; $newQty = (float)$itemData['requested_qty']; $newRemark = $itemData['remark'] ?? null; if ($oldItemsMap->has($productId)) { $oldItem = $oldItemsMap->get($productId); if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) { $diff['updated'][] = [ 'product_name' => $oldItem->product?->name ?? '未知商品', 'old' => [ 'quantity' => (float)$oldItem->requested_qty, 'remark' => $oldItem->remark, ], 'new' => [ 'quantity' => $newQty, 'remark' => $newRemark, ] ]; } $oldItemsMap->forget($productId); } else { $product = \App\Modules\Inventory\Models\Product::find($productId); $diff['added'][] = [ 'product_name' => $product?->name ?? '未知商品', 'new' => [ 'quantity' => $newQty, 'remark' => $newRemark, ] ]; } } // 2. 處理移除 foreach ($oldItemsMap as $productId => $oldItem) { $diff['removed'][] = [ 'product_name' => $oldItem->product?->name ?? '未知商品', 'old' => [ 'quantity' => (float)$oldItem->requested_qty, 'remark' => $oldItem->remark, ] ]; } // 儲存實際變動 $requisition->items()->delete(); $itemsToInsert = []; foreach ($items as $item) { $itemsToInsert[] = [ 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, 'created_at' => now(), 'updated_at' => now(), ]; } StoreRequisitionItem::insert($itemsToInsert); // 檢查是否有任何變動 (主表或明細) $isDirty = $requisition->isDirty(); $hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']); if ($isDirty || $hasItemsDiff) { // 擷取新狀態 $newAttributes = [ 'store_warehouse_id' => $requisition->store_warehouse_id, 'remark' => $requisition->remark, ]; // 靜默更新 $requisition->saveQuietly(); // 手動發送紀錄 activity() ->performedOn($requisition) ->event('updated') ->withProperties([ 'items_diff' => $diff, 'attributes' => $newAttributes, 'old' => $oldAttributes ]) ->log('updated'); } return $requisition->load('items'); }); } /** * 提交審核(draft → pending) */ public function submit(StoreRequisition $requisition, int $userId): StoreRequisition { if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') { throw ValidationException::withMessages([ 'status' => '僅能提交草稿或被駁回的叫貨單', ]); } if ($requisition->items()->count() === 0) { throw ValidationException::withMessages([ 'items' => '叫貨單必須至少有一項商品', ]); } $requisition->update([ 'status' => 'pending', 'submitted_at' => now(), 'reject_reason' => null, ]); // 通知有審核權限的使用者 $this->notifyApprovers($requisition, 'submitted', $userId); return $requisition; } /** * 核准叫貨單(pending → approved),選擇供貨倉庫並自動產生調撥單 */ public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition { if ($requisition->status !== 'pending') { throw ValidationException::withMessages([ 'status' => '僅能核准待審核的叫貨單', ]); } return DB::transaction(function () use ($requisition, $data, $userId) { // 處理前端傳來的明細與批號資料 $processedItems = []; // 暫存處理後的明細,用於轉入調撥單 if (isset($data['items'])) { $requisition->load('items.product'); $reqItemMap = $requisition->items->keyBy('id'); foreach ($data['items'] as $itemData) { $reqItemId = $itemData['id']; $reqItem = $reqItemMap->get($reqItemId); $productName = $reqItem?->product?->name ?? '未知商品'; $totalApprovedQty = 0; $batches = $itemData['batches'] ?? []; // 如果有批號,根據批號展開。若有多個無批號(null)的批次(例如來自不同貨道),則將其數量加總 if (!empty($batches)) { $batchGroups = []; foreach ($batches as $batch) { $qty = (float)($batch['qty'] ?? 0); $bNum = $batch['batch_number'] ?? null; $invId = $batch['inventory_id'] ?? null; if ($qty > 0) { if ($invId) { $inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId); if ($inventory) { $available = max(0, $inventory->quantity - $inventory->reserved_quantity); if ($qty > $available) { $batchStr = $bNum ? "批號 {$bNum}" : "無批號"; throw ValidationException::withMessages([ 'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})", ]); } } } $totalApprovedQty += $qty; $batchKey = $bNum ?? ''; $batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty; } } foreach ($batchGroups as $bNumKey => $qty) { $processedItems[] = [ 'req_item_id' => $reqItemId, 'batch_number' => $bNumKey === '' ? null : $bNumKey, 'quantity' => $qty, ]; } } else { // 無批號,傳統輸入 $qty = (float)($itemData['approved_qty'] ?? 0); if ($qty > 0) { $supplyWarehouseId = $requisition->supply_warehouse_id; $totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId) ->where('product_id', $reqItem->product_id) ->lockForUpdate() // 補上鎖定 ->selectRaw('SUM(quantity - reserved_quantity) as available') ->value('available') ?? 0; if ($qty > $totalAvailable) { throw ValidationException::withMessages([ 'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})", ]); } $totalApprovedQty += $qty; $processedItems[] = [ 'req_item_id' => $reqItemId, 'batch_number' => null, 'quantity' => $qty, ]; } } // 更新叫貨單明細的核准數量總和 StoreRequisitionItem::where('id', $reqItemId) ->where('store_requisition_id', $requisition->id) ->update(['approved_qty' => $totalApprovedQty]); } } // 優先使用傳入的供貨倉庫,若無則從單據中取得 $supplyWarehouseId = $requisition->supply_warehouse_id; if (!$supplyWarehouseId) { throw ValidationException::withMessages([ 'supply_warehouse_id' => '請指定供貨倉庫', ]); } // 查詢供貨倉庫是否有預設在途倉 $supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($supplyWarehouseId); $defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id; // 產生調撥單(供貨倉庫 → 門市倉庫) $transferOrder = $this->transferService->createOrder( fromWarehouseId: $supplyWarehouseId, toWarehouseId: $requisition->store_warehouse_id, remarks: "由叫貨單 {$requisition->doc_no} 自動產生", userId: $userId, transitWarehouseId: $defaultTransitId, ); // 將核准的明細寫入調撥單 $requisition->load('items'); $transferItems = []; // 建立 req_item_id 對應 product_id 的 lookup $reqItemMap = $requisition->items->keyBy('id'); foreach ($processedItems as $pItem) { $reqItem = $reqItemMap->get($pItem['req_item_id']); if ($reqItem) { $transferItems[] = [ 'product_id' => $reqItem->product_id, 'batch_number' => $pItem['batch_number'], 'quantity' => $pItem['quantity'], ]; } } if (!empty($transferItems)) { $this->transferService->updateItems($transferOrder, $transferItems); // 手動發送調撥單的「已建立」合併日誌,包含初始明細 activity() ->performedOn($transferOrder) ->causedBy($userId) ->event('created') ->withProperties(array_merge( ['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []], [ 'attributes' => [ 'doc_no' => $transferOrder->doc_no, 'from_warehouse_id' => $transferOrder->from_warehouse_id, 'to_warehouse_id' => $transferOrder->to_warehouse_id, 'transit_warehouse_id' => $transferOrder->transit_warehouse_id, 'remarks' => $transferOrder->remarks, 'status' => $transferOrder->status, 'created_by' => $transferOrder->created_by, ] ] )) ->log('created'); } // 更新叫貨單狀態 $requisition->update([ 'status' => 'approved', 'supply_warehouse_id' => $supplyWarehouseId, 'approved_by' => $userId, 'approved_at' => now(), 'transfer_order_id' => $transferOrder->id, ]); // 通知申請人 $this->notifyCreator($requisition, 'approved', $userId); return $requisition->load(['items', 'transferOrder']); }); } /** * 駁回叫貨單(pending → rejected) */ public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition { if ($requisition->status !== 'pending') { throw ValidationException::withMessages([ 'status' => '僅能駁回待審核的叫貨單', ]); } $requisition->update([ 'status' => 'rejected', 'reject_reason' => $reason, 'approved_by' => $userId, 'approved_at' => now(), ]); // 通知申請人 $this->notifyCreator($requisition, 'rejected', $userId); return $requisition; } /** * 取消叫貨單 */ public function cancel(StoreRequisition $requisition): StoreRequisition { if (!in_array($requisition->status, ['draft', 'pending'])) { throw ValidationException::withMessages([ 'status' => '僅能取消草稿或待審核的叫貨單', ]); } $requisition->update(['status' => 'cancelled']); return $requisition; } /** * 通知有審核權限的使用者 */ protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void { $actor = User::find($actorId); $actorName = $actor?->name ?? 'System'; // 找出有 store_requisitions.approve 權限的使用者 $approvers = User::permission('store_requisitions.approve')->get(); foreach ($approvers as $approver) { if ($approver->id !== $actorId) { $approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName)); } } } /** * 通知叫貨單申請人 */ protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void { $actor = User::find($actorId); $actorName = $actor?->name ?? 'System'; $creator = User::find($requisition->created_by); if ($creator && $creator->id !== $actorId) { $creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName)); } } }