All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
495 lines
20 KiB
PHP
495 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Modules\Inventory\Services;
|
||
|
||
use App\Modules\Inventory\Models\Inventory;
|
||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||
use App\Modules\Inventory\Models\Warehouse;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||
|
||
class TransferService
|
||
{
|
||
protected InventoryServiceInterface $inventoryService;
|
||
|
||
public function __construct(InventoryServiceInterface $inventoryService)
|
||
{
|
||
$this->inventoryService = $inventoryService;
|
||
}
|
||
|
||
/**
|
||
* 建立調撥單草稿
|
||
*/
|
||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
|
||
{
|
||
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
|
||
if (is_null($transitWarehouseId)) {
|
||
$fromWarehouse = Warehouse::find($fromWarehouseId);
|
||
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
|
||
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
|
||
}
|
||
}
|
||
|
||
$order = new InventoryTransferOrder([
|
||
'from_warehouse_id' => $fromWarehouseId,
|
||
'to_warehouse_id' => $toWarehouseId,
|
||
'transit_warehouse_id' => $transitWarehouseId,
|
||
'status' => 'draft',
|
||
'remarks' => $remarks,
|
||
'created_by' => $userId,
|
||
]);
|
||
|
||
// 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events
|
||
if (empty($order->doc_no)) {
|
||
$today = date('Ymd');
|
||
$prefix = 'TRF-' . $today . '-';
|
||
$lastDoc = InventoryTransferOrder::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';
|
||
}
|
||
$order->doc_no = $prefix . $nextNumber;
|
||
}
|
||
|
||
$order->saveQuietly();
|
||
|
||
return $order;
|
||
}
|
||
|
||
/**
|
||
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
||
*/
|
||
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
||
{
|
||
return DB::transaction(function () use ($order, $itemsData) {
|
||
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||
return [$key => $item];
|
||
});
|
||
|
||
// 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
|
||
foreach ($order->items as $item) {
|
||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
if ($inv) {
|
||
$inv->releaseReservedQuantity($item->quantity);
|
||
}
|
||
}
|
||
|
||
$diff = [
|
||
'added' => [],
|
||
'removed' => [],
|
||
'updated' => [],
|
||
];
|
||
|
||
// 先刪除舊明細
|
||
$order->items()->delete();
|
||
|
||
$itemsToInsert = [];
|
||
$newItemsKeys = [];
|
||
|
||
// 1. 批量收集待插入的明細數據
|
||
foreach ($itemsData as $data) {
|
||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||
$newItemsKeys[] = $key;
|
||
|
||
$itemsToInsert[] = [
|
||
'transfer_order_id' => $order->id,
|
||
'product_id' => $data['product_id'],
|
||
'batch_number' => $data['batch_number'] ?? null,
|
||
'quantity' => $data['quantity'],
|
||
'position' => $data['position'] ?? null,
|
||
'notes' => $data['notes'] ?? null,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
];
|
||
}
|
||
|
||
// 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入)
|
||
if (!empty($itemsToInsert)) {
|
||
InventoryTransferItem::insert($itemsToInsert);
|
||
}
|
||
|
||
// 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model)
|
||
$order->load(['items.product.baseUnit']);
|
||
|
||
foreach ($order->items as $item) {
|
||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||
|
||
// 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全)
|
||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (!$inv) {
|
||
$inv = Inventory::create([
|
||
'warehouse_id' => $order->from_warehouse_id,
|
||
'product_id' => $item->product_id,
|
||
'batch_number' => $item->batch_number,
|
||
'quantity' => 0,
|
||
'unit_cost' => 0,
|
||
'total_value' => 0,
|
||
]);
|
||
$inv = $inv->fresh()->lockForUpdate();
|
||
}
|
||
|
||
$inv->reserveQuantity($item->quantity);
|
||
|
||
// 計算 Diff 用於日誌
|
||
$data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? ''));
|
||
|
||
if ($oldItemsMap->has($key)) {
|
||
$oldItem = $oldItemsMap->get($key);
|
||
if ((float)$oldItem->quantity !== (float)$item->quantity ||
|
||
$oldItem->notes !== $item->notes ||
|
||
$oldItem->position !== $item->position) {
|
||
|
||
$diff['updated'][] = [
|
||
'product_name' => $item->product->name,
|
||
'unit_name' => $item->product->baseUnit?->name,
|
||
'old' => [
|
||
'quantity' => (float)$oldItem->quantity,
|
||
'position' => $oldItem->position,
|
||
'notes' => $oldItem->notes,
|
||
],
|
||
'new' => [
|
||
'quantity' => (float)$item->quantity,
|
||
'position' => $item->position,
|
||
'notes' => $item->notes,
|
||
]
|
||
];
|
||
}
|
||
} else {
|
||
$diff['added'][] = [
|
||
'product_name' => $item->product->name,
|
||
'unit_name' => $item->product->baseUnit?->name,
|
||
'new' => [
|
||
'quantity' => (float)$item->quantity,
|
||
'notes' => $item->notes,
|
||
]
|
||
];
|
||
}
|
||
}
|
||
|
||
foreach ($oldItemsMap as $key => $oldItem) {
|
||
if (!in_array($key, $newItemsKeys)) {
|
||
$diff['removed'][] = [
|
||
'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
|
||
'unit_name' => $oldItem->product?->baseUnit?->name,
|
||
'old' => [
|
||
'quantity' => (float)$oldItem->quantity,
|
||
'notes' => $oldItem->notes,
|
||
]
|
||
];
|
||
}
|
||
}
|
||
|
||
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||
if ($hasChanged) {
|
||
$order->activityProperties['items_diff'] = $diff;
|
||
}
|
||
|
||
return $hasChanged;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||
*/
|
||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||
{
|
||
$order->load('items.product');
|
||
|
||
DB::transaction(function () use ($order, $userId) {
|
||
$fromWarehouse = $order->fromWarehouse;
|
||
$hasTransit = !empty($order->transit_warehouse_id);
|
||
|
||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||
|
||
$itemsDiff = [];
|
||
|
||
foreach ($order->items as $item) {
|
||
if ($item->quantity <= 0) continue;
|
||
|
||
// 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
|
||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||
$availableQty = $sourceInventory->quantity ?? 0;
|
||
$shortageQty = $item->quantity - $availableQty;
|
||
throw ValidationException::withMessages([
|
||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"],
|
||
]);
|
||
}
|
||
|
||
$sourceBefore = (float) $sourceInventory->quantity;
|
||
|
||
// 釋放草稿階段預扣的庫存
|
||
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
|
||
$sourceInventory->saveQuietly();
|
||
|
||
$item->update(['snapshot_quantity' => $sourceBefore]);
|
||
|
||
// 委託 InventoryService 處理扣庫與 Transaction
|
||
$this->inventoryService->decreaseInventoryQuantity(
|
||
$sourceInventory->id,
|
||
$item->quantity,
|
||
"調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||
InventoryTransferOrder::class,
|
||
$order->id
|
||
);
|
||
|
||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||
|
||
// 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
|
||
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||
|
||
$this->inventoryService->createInventoryRecord([
|
||
'warehouse_id' => $targetWarehouseId,
|
||
'product_id' => $item->product_id,
|
||
'quantity' => $item->quantity,
|
||
'unit_cost' => $sourceInventory->unit_cost,
|
||
'batch_number' => $item->batch_number,
|
||
'expiry_date' => $sourceInventory->expiry_date,
|
||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||
'reference_type' => InventoryTransferOrder::class,
|
||
'reference_id' => $order->id,
|
||
'location' => $hasTransit ? null : ($item->position ?? null),
|
||
'origin_country' => $sourceInventory->origin_country,
|
||
'quality_status' => $sourceInventory->quality_status,
|
||
]);
|
||
|
||
$targetAfter = $targetBefore + (float) $item->quantity;
|
||
|
||
// 記錄異動明細供整合日誌使用
|
||
$itemsDiff[] = [
|
||
'product_name' => $item->product->name,
|
||
'batch_number' => $item->batch_number,
|
||
'quantity' => (float)$item->quantity,
|
||
'source_warehouse' => $fromWarehouse->name,
|
||
'source_before' => $sourceBefore,
|
||
'source_after' => $sourceAfter,
|
||
'target_warehouse' => $targetWarehouse->name,
|
||
'target_before' => $targetBefore,
|
||
'target_after' => $targetAfter,
|
||
];
|
||
}
|
||
|
||
$oldStatus = $order->status;
|
||
if ($hasTransit) {
|
||
$order->status = 'dispatched';
|
||
$order->dispatched_at = now();
|
||
$order->dispatched_by = $userId;
|
||
} else {
|
||
$order->status = 'completed';
|
||
$order->posted_at = now();
|
||
$order->posted_by = $userId;
|
||
}
|
||
$order->saveQuietly();
|
||
|
||
// 手動觸發單一合併日誌
|
||
activity()
|
||
->performedOn($order)
|
||
->causedBy(auth()->user())
|
||
->event('updated')
|
||
->withProperties([
|
||
'items_diff' => $itemsDiff,
|
||
'attributes' => [
|
||
'status' => $order->status,
|
||
'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null,
|
||
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null,
|
||
'dispatched_by' => $order->dispatched_by,
|
||
'posted_by' => $order->posted_by,
|
||
],
|
||
'old' => [
|
||
'status' => $oldStatus,
|
||
]
|
||
])
|
||
->log($order->status == 'completed' ? 'posted' : 'dispatched');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||
*/
|
||
public function receive(InventoryTransferOrder $order, int $userId): void
|
||
{
|
||
if ($order->status !== 'dispatched') {
|
||
throw new \Exception('僅能對已出貨的調撥單進行收貨確認');
|
||
}
|
||
|
||
if (empty($order->transit_warehouse_id)) {
|
||
throw new \Exception('此調撥單未設定在途倉庫');
|
||
}
|
||
|
||
$order->load('items.product');
|
||
|
||
DB::transaction(function () use ($order, $userId) {
|
||
$transitWarehouse = $order->transitWarehouse;
|
||
$toWarehouse = $order->toWarehouse;
|
||
|
||
$itemsDiff = [];
|
||
|
||
foreach ($order->items as $item) {
|
||
if ($item->quantity <= 0) continue;
|
||
|
||
// 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
|
||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||
$availableQty = $transitInventory->quantity ?? 0;
|
||
throw ValidationException::withMessages([
|
||
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"],
|
||
]);
|
||
}
|
||
|
||
$transitBefore = (float) $transitInventory->quantity;
|
||
|
||
// 委託 InventoryService 處理扣庫與 Transaction
|
||
$this->inventoryService->decreaseInventoryQuantity(
|
||
$transitInventory->id,
|
||
$item->quantity,
|
||
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
||
InventoryTransferOrder::class,
|
||
$order->id
|
||
);
|
||
|
||
$transitAfter = $transitBefore - (float) $item->quantity;
|
||
|
||
// 2. 目的倉增加 - 同樣需要鎖定
|
||
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||
|
||
$this->inventoryService->createInventoryRecord([
|
||
'warehouse_id' => $order->to_warehouse_id,
|
||
'product_id' => $item->product_id,
|
||
'quantity' => $item->quantity,
|
||
'unit_cost' => $transitInventory->unit_cost,
|
||
'batch_number' => $item->batch_number,
|
||
'expiry_date' => $transitInventory->expiry_date,
|
||
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
||
'reference_type' => InventoryTransferOrder::class,
|
||
'reference_id' => $order->id,
|
||
'location' => $item->position,
|
||
'origin_country' => $transitInventory->origin_country,
|
||
'quality_status' => $transitInventory->quality_status,
|
||
]);
|
||
|
||
$targetAfter = $targetBefore + (float) $item->quantity;
|
||
|
||
$itemsDiff[] = [
|
||
'product_name' => $item->product->name,
|
||
'batch_number' => $item->batch_number,
|
||
'quantity' => (float)$item->quantity,
|
||
'source_warehouse' => $transitWarehouse->name,
|
||
'source_before' => $transitBefore,
|
||
'source_after' => $transitAfter,
|
||
'target_warehouse' => $toWarehouse->name,
|
||
'target_before' => $targetBefore,
|
||
'target_after' => $targetAfter,
|
||
];
|
||
}
|
||
|
||
$oldStatus = $order->status;
|
||
$order->status = 'completed';
|
||
$order->posted_at = now();
|
||
$order->posted_by = $userId;
|
||
$order->received_at = now();
|
||
$order->received_by = $userId;
|
||
$order->saveQuietly();
|
||
|
||
// 手動觸發單一合併日誌
|
||
activity()
|
||
->performedOn($order)
|
||
->causedBy(auth()->user())
|
||
->event('updated')
|
||
->withProperties([
|
||
'items_diff' => $itemsDiff,
|
||
'attributes' => [
|
||
'status' => 'completed',
|
||
'posted_at' => $order->posted_at->format('Y-m-d H:i:s'),
|
||
'received_at' => $order->received_at->format('Y-m-d H:i:s'),
|
||
'posted_by' => $order->posted_by,
|
||
'received_by' => $order->received_by,
|
||
],
|
||
'old' => [
|
||
'status' => $oldStatus,
|
||
]
|
||
])
|
||
->log('received');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 作廢 (Void) - 僅限草稿狀態
|
||
*/
|
||
public function void(InventoryTransferOrder $order, int $userId): void
|
||
{
|
||
if ($order->status !== 'draft') {
|
||
throw new \Exception('只能作廢草稿狀態的單據');
|
||
}
|
||
|
||
DB::transaction(function () use ($order, $userId) {
|
||
foreach ($order->items as $item) {
|
||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||
->where('product_id', $item->product_id)
|
||
->where('batch_number', $item->batch_number)
|
||
->lockForUpdate()
|
||
->first();
|
||
if ($inv) {
|
||
$inv->releaseReservedQuantity($item->quantity);
|
||
}
|
||
}
|
||
|
||
$oldStatus = $order->status;
|
||
$order->status = 'voided';
|
||
$order->updated_by = $userId;
|
||
$order->saveQuietly();
|
||
|
||
activity()
|
||
->performedOn($order)
|
||
->causedBy(auth()->user())
|
||
->event('updated')
|
||
->withProperties([
|
||
'attributes' => [
|
||
'status' => 'voided',
|
||
],
|
||
'old' => [
|
||
'status' => $oldStatus,
|
||
]
|
||
])
|
||
->log('voided');
|
||
});
|
||
}
|
||
}
|