2026-01-28 18:04:45 +08:00
|
|
|
|
<?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;
|
|
|
|
|
|
|
|
|
|
|
|
class TransferService
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 建立調撥單草稿
|
|
|
|
|
|
*/
|
2026-02-13 13:16:05 +08:00
|
|
|
|
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
|
2026-01-28 18:04:45 +08:00
|
|
|
|
{
|
2026-02-13 13:16:05 +08:00
|
|
|
|
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
|
|
|
|
|
|
if (is_null($transitWarehouseId)) {
|
|
|
|
|
|
$fromWarehouse = Warehouse::find($fromWarehouseId);
|
|
|
|
|
|
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
|
|
|
|
|
|
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 18:04:45 +08:00
|
|
|
|
return InventoryTransferOrder::create([
|
|
|
|
|
|
'from_warehouse_id' => $fromWarehouseId,
|
|
|
|
|
|
'to_warehouse_id' => $toWarehouseId,
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'transit_warehouse_id' => $transitWarehouseId,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'status' => 'draft',
|
|
|
|
|
|
'remarks' => $remarks,
|
|
|
|
|
|
'created_by' => $userId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 17:51:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
|
|
|
|
|
*/
|
2026-02-05 09:33:36 +08:00
|
|
|
|
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
2026-01-28 18:04:45 +08:00
|
|
|
|
{
|
2026-02-05 09:33:36 +08:00
|
|
|
|
return DB::transaction(function () use ($order, $itemsData) {
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
|
|
|
|
|
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
|
|
|
|
|
return [$key => $item];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$diff = [
|
|
|
|
|
|
'added' => [],
|
|
|
|
|
|
'removed' => [],
|
|
|
|
|
|
'updated' => [],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-28 18:04:45 +08:00
|
|
|
|
$order->items()->delete();
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$newItemsKeys = [];
|
|
|
|
|
|
|
2026-01-28 18:04:45 +08:00
|
|
|
|
foreach ($itemsData as $data) {
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
|
|
|
|
|
$newItemsKeys[] = $key;
|
|
|
|
|
|
|
|
|
|
|
|
$item = $order->items()->create([
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'product_id' => $data['product_id'],
|
|
|
|
|
|
'batch_number' => $data['batch_number'] ?? null,
|
|
|
|
|
|
'quantity' => $data['quantity'],
|
2026-02-09 16:52:35 +08:00
|
|
|
|
'position' => $data['position'] ?? null,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'notes' => $data['notes'] ?? null,
|
|
|
|
|
|
]);
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$item->load('product');
|
|
|
|
|
|
|
|
|
|
|
|
if ($oldItemsMap->has($key)) {
|
|
|
|
|
|
$oldItem = $oldItemsMap->get($key);
|
2026-02-09 16:52:35 +08:00
|
|
|
|
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
|
|
|
|
|
$oldItem->notes !== ($data['notes'] ?? null) ||
|
|
|
|
|
|
$oldItem->position !== ($data['position'] ?? null)) {
|
2026-02-04 17:51:29 +08:00
|
|
|
|
|
|
|
|
|
|
$diff['updated'][] = [
|
|
|
|
|
|
'product_name' => $item->product->name,
|
|
|
|
|
|
'old' => [
|
|
|
|
|
|
'quantity' => (float)$oldItem->quantity,
|
2026-02-09 16:52:35 +08:00
|
|
|
|
'position' => $oldItem->position,
|
2026-02-04 17:51:29 +08:00
|
|
|
|
'notes' => $oldItem->notes,
|
|
|
|
|
|
],
|
|
|
|
|
|
'new' => [
|
|
|
|
|
|
'quantity' => (float)$data['quantity'],
|
2026-02-09 16:52:35 +08:00
|
|
|
|
'position' => $item->position,
|
2026-02-04 17:51:29 +08:00
|
|
|
|
'notes' => $item->notes,
|
|
|
|
|
|
]
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-02-05 09:33:36 +08:00
|
|
|
|
$diff['updated'][] = [
|
2026-02-04 17:51:29 +08:00
|
|
|
|
'product_name' => $item->product->name,
|
2026-02-05 09:33:36 +08:00
|
|
|
|
'old' => [
|
|
|
|
|
|
'quantity' => 0,
|
|
|
|
|
|
'notes' => null,
|
|
|
|
|
|
],
|
2026-02-04 17:51:29 +08:00
|
|
|
|
'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,
|
|
|
|
|
|
'old' => [
|
|
|
|
|
|
'quantity' => (float)$oldItem->quantity,
|
|
|
|
|
|
'notes' => $oldItem->notes,
|
|
|
|
|
|
]
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 09:33:36 +08:00
|
|
|
|
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
|
|
|
|
|
if ($hasChanged) {
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$order->activityProperties['items_diff'] = $diff;
|
2026-01-28 18:04:45 +08:00
|
|
|
|
}
|
2026-02-05 09:33:36 +08:00
|
|
|
|
|
|
|
|
|
|
return $hasChanged;
|
2026-01-28 18:04:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-13 13:16:05 +08:00
|
|
|
|
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
|
|
|
|
|
*
|
|
|
|
|
|
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
|
|
|
|
|
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
2026-01-28 18:04:45 +08:00
|
|
|
|
*/
|
2026-02-13 13:16:05 +08:00
|
|
|
|
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
2026-01-28 18:04:45 +08:00
|
|
|
|
{
|
2026-02-04 17:51:29 +08:00
|
|
|
|
$order->load('items.product');
|
|
|
|
|
|
|
2026-01-28 18:04:45 +08:00
|
|
|
|
DB::transaction(function () use ($order, $userId) {
|
|
|
|
|
|
$fromWarehouse = $order->fromWarehouse;
|
2026-02-13 13:16:05 +08:00
|
|
|
|
$hasTransit = !empty($order->transit_warehouse_id);
|
|
|
|
|
|
|
|
|
|
|
|
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
|
|
|
|
|
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
|
|
|
|
|
|
|
|
|
|
|
$outType = '調撥出庫';
|
|
|
|
|
|
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
2026-01-28 18:04:45 +08:00
|
|
|
|
|
|
|
|
|
|
foreach ($order->items as $item) {
|
|
|
|
|
|
if ($item->quantity <= 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 處理來源倉 (扣除)
|
|
|
|
|
|
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
|
|
|
|
|
->where('product_id', $item->product_id)
|
|
|
|
|
|
->where('batch_number', $item->batch_number)
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
2026-02-09 16:52:35 +08:00
|
|
|
|
$availableQty = $sourceInventory->quantity ?? 0;
|
|
|
|
|
|
$shortageQty = $item->quantity - $availableQty;
|
2026-01-28 18:04:45 +08:00
|
|
|
|
throw ValidationException::withMessages([
|
2026-02-09 16:52:35 +08:00
|
|
|
|
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"],
|
2026-01-28 18:04:45 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$oldSourceQty = $sourceInventory->quantity;
|
|
|
|
|
|
$newSourceQty = $oldSourceQty - $item->quantity;
|
2026-02-05 09:33:36 +08:00
|
|
|
|
|
|
|
|
|
|
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
2026-01-28 18:04:45 +08:00
|
|
|
|
|
|
|
|
|
|
$sourceInventory->quantity = $newSourceQty;
|
|
|
|
|
|
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
|
|
|
|
|
$sourceInventory->save();
|
|
|
|
|
|
|
|
|
|
|
|
$sourceInventory->transactions()->create([
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'type' => $outType,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'quantity' => -$item->quantity,
|
|
|
|
|
|
'unit_cost' => $sourceInventory->unit_cost,
|
|
|
|
|
|
'balance_before' => $oldSourceQty,
|
|
|
|
|
|
'balance_after' => $newSourceQty,
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'reason' => "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'actual_time' => now(),
|
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-02-13 13:16:05 +08:00
|
|
|
|
// 2. 處理目的倉/在途倉 (增加)
|
2026-01-28 18:04:45 +08:00
|
|
|
|
$targetInventory = Inventory::firstOrCreate(
|
|
|
|
|
|
[
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'warehouse_id' => $targetWarehouseId,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'product_id' => $item->product_id,
|
|
|
|
|
|
'batch_number' => $item->batch_number,
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'location' => $hasTransit ? null : ($item->position ?? null),
|
2026-01-28 18:04:45 +08:00
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
'quantity' => 0,
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'unit_cost' => $sourceInventory->unit_cost,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'total_value' => 0,
|
|
|
|
|
|
'expiry_date' => $sourceInventory->expiry_date,
|
|
|
|
|
|
'quality_status' => $sourceInventory->quality_status,
|
|
|
|
|
|
'origin_country' => $sourceInventory->origin_country,
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
|
|
|
|
|
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$oldTargetQty = $targetInventory->quantity;
|
|
|
|
|
|
$newTargetQty = $oldTargetQty + $item->quantity;
|
|
|
|
|
|
|
|
|
|
|
|
$targetInventory->quantity = $newTargetQty;
|
|
|
|
|
|
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
|
|
|
|
|
$targetInventory->save();
|
|
|
|
|
|
|
|
|
|
|
|
$targetInventory->transactions()->create([
|
2026-02-13 13:16:05 +08:00
|
|
|
|
'type' => $inType,
|
2026-01-28 18:04:45 +08:00
|
|
|
|
'quantity' => $item->quantity,
|
|
|
|
|
|
'unit_cost' => $targetInventory->unit_cost,
|
|
|
|
|
|
'balance_before' => $oldTargetQty,
|
|
|
|
|
|
'balance_after' => $newTargetQty,
|
|
|
|
|
|
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
|
|
|
|
|
'actual_time' => now(),
|
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 13:16:05 +08:00
|
|
|
|
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->save();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
|
|
|
|
|
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
|
|
|
|
|
*/
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($order->items as $item) {
|
|
|
|
|
|
if ($item->quantity <= 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 在途倉扣除
|
|
|
|
|
|
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
|
|
|
|
|
->where('product_id', $item->product_id)
|
|
|
|
|
|
->where('batch_number', $item->batch_number)
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
|
|
|
|
|
$availableQty = $transitInventory->quantity ?? 0;
|
|
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
|
|
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$oldTransitQty = $transitInventory->quantity;
|
|
|
|
|
|
$newTransitQty = $oldTransitQty - $item->quantity;
|
|
|
|
|
|
|
|
|
|
|
|
$transitInventory->quantity = $newTransitQty;
|
|
|
|
|
|
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
|
|
|
|
|
|
$transitInventory->save();
|
|
|
|
|
|
|
|
|
|
|
|
$transitInventory->transactions()->create([
|
|
|
|
|
|
'type' => '在途出庫',
|
|
|
|
|
|
'quantity' => -$item->quantity,
|
|
|
|
|
|
'unit_cost' => $transitInventory->unit_cost,
|
|
|
|
|
|
'balance_before' => $oldTransitQty,
|
|
|
|
|
|
'balance_after' => $newTransitQty,
|
|
|
|
|
|
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
|
|
|
|
|
'actual_time' => now(),
|
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 目的倉增加
|
|
|
|
|
|
$targetInventory = Inventory::firstOrCreate(
|
|
|
|
|
|
[
|
|
|
|
|
|
'warehouse_id' => $order->to_warehouse_id,
|
|
|
|
|
|
'product_id' => $item->product_id,
|
|
|
|
|
|
'batch_number' => $item->batch_number,
|
|
|
|
|
|
'location' => $item->position,
|
2026-02-04 17:51:29 +08:00
|
|
|
|
],
|
2026-02-13 13:16:05 +08:00
|
|
|
|
[
|
|
|
|
|
|
'quantity' => 0,
|
|
|
|
|
|
'unit_cost' => $transitInventory->unit_cost,
|
|
|
|
|
|
'total_value' => 0,
|
|
|
|
|
|
'expiry_date' => $transitInventory->expiry_date,
|
|
|
|
|
|
'quality_status' => $transitInventory->quality_status,
|
|
|
|
|
|
'origin_country' => $transitInventory->origin_country,
|
2026-02-04 17:51:29 +08:00
|
|
|
|
]
|
2026-02-13 13:16:05 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
|
|
|
|
|
$targetInventory->unit_cost = $transitInventory->unit_cost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$oldTargetQty = $targetInventory->quantity;
|
|
|
|
|
|
$newTargetQty = $oldTargetQty + $item->quantity;
|
|
|
|
|
|
|
|
|
|
|
|
$targetInventory->quantity = $newTargetQty;
|
|
|
|
|
|
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
|
|
|
|
|
$targetInventory->save();
|
|
|
|
|
|
|
|
|
|
|
|
$targetInventory->transactions()->create([
|
|
|
|
|
|
'type' => '調撥入庫',
|
|
|
|
|
|
'quantity' => $item->quantity,
|
|
|
|
|
|
'unit_cost' => $targetInventory->unit_cost,
|
|
|
|
|
|
'balance_before' => $oldTargetQty,
|
|
|
|
|
|
'balance_after' => $newTargetQty,
|
|
|
|
|
|
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
|
|
|
|
|
'actual_time' => now(),
|
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
2026-02-04 17:51:29 +08:00
|
|
|
|
|
|
|
|
|
|
$order->status = 'completed';
|
|
|
|
|
|
$order->posted_at = now();
|
|
|
|
|
|
$order->posted_by = $userId;
|
2026-02-13 13:16:05 +08:00
|
|
|
|
$order->received_at = now();
|
|
|
|
|
|
$order->received_by = $userId;
|
|
|
|
|
|
$order->save();
|
2026-01-28 18:04:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 13:16:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 作廢 (Void) - 僅限草稿狀態
|
|
|
|
|
|
*/
|
2026-01-28 18:04:45 +08:00
|
|
|
|
public function void(InventoryTransferOrder $order, int $userId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($order->status !== 'draft') {
|
|
|
|
|
|
throw new \Exception('只能作廢草稿狀態的單據');
|
|
|
|
|
|
}
|
|
|
|
|
|
$order->update([
|
|
|
|
|
|
'status' => 'voided',
|
|
|
|
|
|
'updated_by' => $userId
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|