Files
star-erp/app/Modules/Inventory/Services/TransferService.php
2026-03-02 16:42:12 +08:00

470 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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)
->first();
if ($inv) {
$inv->releaseReservedQuantity($item->quantity);
}
}
$diff = [
'added' => [],
'removed' => [],
'updated' => [],
];
$order->items()->delete();
$newItemsKeys = [];
foreach ($itemsData as $data) {
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
$newItemsKeys[] = $key;
$item = $order->items()->create([
'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'],
'position' => $data['position'] ?? null,
'notes' => $data['notes'] ?? null,
]);
$item->load('product');
// 增加新明細的預扣庫存
$inv = Inventory::firstOrCreate(
[
'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->reserveQuantity($item->quantity);
if ($oldItemsMap->has($key)) {
$oldItem = $oldItemsMap->get($key);
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
$oldItem->notes !== ($data['notes'] ?? null) ||
$oldItem->position !== ($data['position'] ?? null)) {
$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)$data['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,
'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) - 根據是否有在途倉決定流程
*
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed維持原有邏輯
*/
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;
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
$itemsDiff = [];
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) {
$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. 處理目的倉/在途倉 (增加)
// 獲取目的倉異動前的庫存數(若無則為 0
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->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) - 在途倉扣除 → 目的倉增加
* 僅適用於有在途倉且狀態為 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;
$itemsDiff = [];
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}"],
]);
}
$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)
->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)
->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');
});
}
}