inventoryService = $inventoryService; $this->procurementService = $procurementService; } /** * Store a new Goods Receipt (Draft state). * * @param array $data * @return GoodsReceipt * @throws \Exception */ public function store(array $data) { return DB::transaction(function () use ($data) { // 1. Generate Code $data['code'] = $this->generateCode($data['received_date']); $data['user_id'] = auth()->id(); $data['status'] = GoodsReceipt::STATUS_DRAFT; // 預設草稿 // 2. Create Header $goodsReceipt = GoodsReceipt::create($data); // 3. Process Items foreach ($data['items'] as $itemData) { // Create GR Item $grItem = new GoodsReceiptItem([ 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, ]); $goodsReceipt->items()->save($grItem); } return $goodsReceipt; }); } public function update(GoodsReceipt $goodsReceipt, array $data) { if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { throw new \Exception('只有草稿或被退回的進貨單可以修改。'); } return DB::transaction(function () use ($goodsReceipt, $data) { $goodsReceipt->fill([ 'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id, 'received_date' => $data['received_date'] ?? $goodsReceipt->received_date, 'remarks' => $data['remarks'] ?? $goodsReceipt->remarks, ]); $dirty = $goodsReceipt->getDirty(); $oldAttributes = []; $newAttributes = []; foreach ($dirty as $key => $value) { $oldAttributes[$key] = $goodsReceipt->getOriginal($key); $newAttributes[$key] = $value; } // 儲存但不觸發事件,以避免重複記錄 $goodsReceipt->saveQuietly(); // 捕捉包含商品名稱的舊項目以進行比對 $oldItemsCollection = $goodsReceipt->items()->get(); $oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray(); $oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id'); $oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) { $product = $oldProducts->get($item->product_id); return [ 'id' => $item->id, 'product_id' => $item->product_id, 'product_name' => $product?->name ?? 'Unknown', 'quantity_received' => (float) $item->quantity_received, 'unit_price' => (float) $item->unit_price, 'total_amount' => (float) $item->total_amount, ]; })->keyBy('product_id'); if (isset($data['items'])) { $goodsReceipt->items()->delete(); foreach ($data['items'] as $itemData) { $grItem = new GoodsReceiptItem([ 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, ]); $goodsReceipt->items()->save($grItem); } } // 計算項目差異 $itemDiffs = [ 'added' => [], 'removed' => [], 'updated' => [], ]; $newItemsCollection = $goodsReceipt->items()->get(); $newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray(); $newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id'); $newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) { $product = $newProducts->get($item->product_id); return [ 'product_id' => $item->product_id, 'product_name' => $product?->name ?? 'Unknown', 'quantity_received' => (float) $item->quantity_received, 'unit_price' => (float) $item->unit_price, 'total_amount' => (float) $item->total_amount, ]; })->keyBy('product_id'); foreach ($oldItems as $productId => $oldItem) { if (!$newItemsFormatted->has($productId)) { $itemDiffs['removed'][] = $oldItem; } } foreach ($newItemsFormatted as $productId => $newItem) { if (!$oldItems->has($productId)) { $itemDiffs['added'][] = $newItem; } else { $oldItem = $oldItems[$productId]; if ( $oldItem['quantity_received'] != $newItem['quantity_received'] || $oldItem['unit_price'] != $newItem['unit_price'] || $oldItem['total_amount'] != $newItem['total_amount'] ) { $itemDiffs['updated'][] = [ 'product_name' => $newItem['product_name'], 'old' => [ 'quantity_received' => $oldItem['quantity_received'], 'unit_price' => $oldItem['unit_price'], 'total_amount' => $oldItem['total_amount'], ], 'new' => [ 'quantity_received' => $newItem['quantity_received'], 'unit_price' => $newItem['unit_price'], 'total_amount' => $newItem['total_amount'], ] ]; } } } // 如果有變更,手動觸發單一合併日誌 if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) { activity() ->performedOn($goodsReceipt) ->causedBy(auth()->user()) ->event('updated') ->withProperties([ 'attributes' => $newAttributes, 'old' => $oldAttributes, 'items_diff' => $itemDiffs, ]) ->log('updated'); } return $goodsReceipt->fresh('items'); }); } /** * Submit for audit (Confirm receipt by warehouse staff). * This will increase inventory and update PO. * * @param GoodsReceipt $goodsReceipt * @return GoodsReceipt * @throws \Exception */ public function submit(GoodsReceipt $goodsReceipt) { if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); } return DB::transaction(function () use ($goodsReceipt) { $goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED; $goodsReceipt->save(); // Process Inventory and PO updates foreach ($goodsReceipt->items as $grItem) { // 1. Update Inventory $reason = match($goodsReceipt->type) { 'standard' => '採購進貨', 'miscellaneous' => '雜項入庫', 'other' => '其他入庫', default => '進貨入庫', }; $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $goodsReceipt->warehouse_id, 'product_id' => $grItem->product_id, 'quantity' => $grItem->quantity_received, 'unit_cost' => $grItem->unit_price, 'batch_number' => $grItem->batch_number, 'expiry_date' => $grItem->expiry_date, 'reason' => $reason, 'reference_type' => GoodsReceipt::class, 'reference_id' => $goodsReceipt->id, 'source_purchase_order_id' => $goodsReceipt->purchase_order_id, 'arrival_date' => $goodsReceipt->received_date, ]); // 2. Update PO if linked and type is standard if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) { $this->procurementService->updateReceivedQuantity( $grItem->purchase_order_item_id, $grItem->quantity_received ); } } // Fire event to let Finance module create AP event(new GoodsReceiptApprovedEvent($goodsReceipt->id)); return $goodsReceipt; }); } private function generateCode(string $date): string { // 使用 Cache Lock 防止併發時產生重複單號 $lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10); if (!$lock->get()) { throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試'); } try { // Format: GR-YYYYMMDD-NN $prefix = 'GR-' . date('Ymd', strtotime($date)) . '-'; $last = GoodsReceipt::where('code', 'like', $prefix . '%') ->orderBy('id', 'desc') ->first(); if ($last) { $seq = intval(substr($last->code, -2)) + 1; } else { $seq = 1; } $code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT); return $code; } finally { $lock->release(); } } /** * 獲取指定的進貨單資訊 (實作 GoodsReceiptServiceInterface) * * @param int $goodsReceiptId * @return array|null */ public function getGoodsReceiptData(int $goodsReceiptId): ?array { $receipt = GoodsReceipt::with('items')->find($goodsReceiptId); if (!$receipt) { return null; } // 以陣列形式回傳資料,避免外部模組產生 Model 依賴 return $receipt->toArray(); } }