1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。 2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。 3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。 4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
349 lines
14 KiB
PHP
349 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Inventory\Services;
|
|
|
|
use App\Modules\Inventory\Models\GoodsReceipt;
|
|
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface
|
|
{
|
|
protected $inventoryService;
|
|
protected $procurementService;
|
|
|
|
public function __construct(
|
|
InventoryServiceInterface $inventoryService,
|
|
ProcurementServiceInterface $procurementService
|
|
) {
|
|
$this->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. 靜默建立以抑制自動日誌(後續手動發送含品項明細的日誌)
|
|
$goodsReceipt = new GoodsReceipt($data);
|
|
$goodsReceipt->saveQuietly();
|
|
|
|
// 3. 建立品項並收集 items_diff
|
|
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
|
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
|
|
|
foreach ($data['items'] as $itemData) {
|
|
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
|
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
|
? (float) $itemData['subtotal']
|
|
: $itemData['quantity_received'] * $itemData['unit_price'];
|
|
|
|
// 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' => $totalAmount,
|
|
'batch_number' => $itemData['batch_number'] ?? null,
|
|
'expiry_date' => $itemData['expiry_date'] ?? null,
|
|
]);
|
|
$goodsReceipt->items()->save($grItem);
|
|
|
|
$product = $products->get($itemData['product_id']);
|
|
$diff['added'][] = [
|
|
'product_name' => $product?->name ?? '未知商品',
|
|
'new' => [
|
|
'quantity_received' => (float)$itemData['quantity_received'],
|
|
'unit_price' => (float)$itemData['unit_price'],
|
|
'total_amount' => (float)$totalAmount,
|
|
]
|
|
];
|
|
}
|
|
|
|
// 4. 手動發送高品質日誌(包含品項明細)
|
|
activity()
|
|
->performedOn($goodsReceipt)
|
|
->causedBy(auth()->user())
|
|
->event('created')
|
|
->withProperties([
|
|
'items_diff' => $diff,
|
|
'attributes' => [
|
|
'gr_number' => $goodsReceipt->code,
|
|
'type' => $goodsReceipt->type,
|
|
'warehouse_id' => $goodsReceipt->warehouse_id,
|
|
'vendor_id' => $goodsReceipt->vendor_id,
|
|
'purchase_order_id' => $goodsReceipt->purchase_order_id,
|
|
'received_date' => $goodsReceipt->received_date,
|
|
'status' => $goodsReceipt->status,
|
|
'remarks' => $goodsReceipt->remarks,
|
|
'user_id' => $goodsReceipt->user_id,
|
|
]
|
|
])
|
|
->log('created');
|
|
|
|
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) {
|
|
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
|
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
|
? (float) $itemData['subtotal']
|
|
: $itemData['quantity_received'] * $itemData['unit_price'];
|
|
|
|
$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' => $totalAmount,
|
|
'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();
|
|
}
|
|
}
|