All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
509 lines
20 KiB
PHP
509 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Modules\Inventory\Services;
|
||
|
||
use App\Modules\Inventory\Models\StoreRequisition;
|
||
use App\Modules\Inventory\Models\StoreRequisitionItem;
|
||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
|
||
use App\Modules\Core\Models\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class StoreRequisitionService
|
||
{
|
||
protected TransferService $transferService;
|
||
|
||
public function __construct(TransferService $transferService)
|
||
{
|
||
$this->transferService = $transferService;
|
||
}
|
||
|
||
/**
|
||
* 建立叫貨單(含明細)
|
||
*/
|
||
public function create(array $data, array $items, int $userId, bool $submitImmediately = false): StoreRequisition
|
||
{
|
||
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
|
||
$requisition = new StoreRequisition([
|
||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||
'status' => $submitImmediately ? 'pending' : 'draft',
|
||
'submitted_at' => $submitImmediately ? now() : null,
|
||
'remark' => $data['remark'] ?? null,
|
||
'created_by' => $userId,
|
||
]);
|
||
|
||
// 手動產生單號,因為 saveQuietly 會繞過模型事件
|
||
if (empty($requisition->doc_no)) {
|
||
$today = date('Ymd');
|
||
$prefix = 'SR-' . $today . '-';
|
||
$lastDoc = StoreRequisition::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';
|
||
}
|
||
$requisition->doc_no = $prefix . $nextNumber;
|
||
}
|
||
|
||
// 靜默建立以抑制自動日誌
|
||
$requisition->saveQuietly();
|
||
|
||
$itemsToInsert = [];
|
||
$productIds = collect($items)->pluck('product_id')->unique()->toArray();
|
||
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||
|
||
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||
foreach ($items as $item) {
|
||
$itemsToInsert[] = [
|
||
'store_requisition_id' => $requisition->id,
|
||
'product_id' => $item['product_id'],
|
||
'requested_qty' => $item['requested_qty'],
|
||
'remark' => $item['remark'] ?? null,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
];
|
||
|
||
$product = $products->get($item['product_id']);
|
||
$diff['added'][] = [
|
||
'product_name' => $product?->name ?? '未知商品',
|
||
'new' => [
|
||
'quantity' => (float)$item['requested_qty'],
|
||
'remark' => $item['remark'] ?? null,
|
||
]
|
||
];
|
||
}
|
||
StoreRequisitionItem::insert($itemsToInsert);
|
||
|
||
// 如果需直接提交,觸發通知
|
||
if ($submitImmediately) {
|
||
$this->notifyApprovers($requisition, 'submitted', $userId);
|
||
}
|
||
|
||
// 手動發送高品質日誌
|
||
activity()
|
||
->performedOn($requisition)
|
||
->causedBy($userId)
|
||
->event('created')
|
||
->withProperties([
|
||
'items_diff' => $diff,
|
||
'attributes' => [
|
||
'doc_no' => $requisition->doc_no,
|
||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||
'status' => $requisition->status,
|
||
'remark' => $requisition->remark,
|
||
'created_by' => $requisition->created_by,
|
||
'submitted_at' => $requisition->submitted_at,
|
||
]
|
||
])
|
||
->log('created');
|
||
|
||
return $requisition->load('items');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新叫貨單(僅限 draft / rejected 狀態)
|
||
*/
|
||
public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition
|
||
{
|
||
if (!in_array($requisition->status, ['draft', 'rejected'])) {
|
||
throw ValidationException::withMessages([
|
||
'status' => '僅能編輯草稿或被駁回的叫貨單',
|
||
]);
|
||
}
|
||
|
||
return DB::transaction(function () use ($requisition, $data, $items) {
|
||
// 擷取舊狀態供日誌對照
|
||
$oldAttributes = [
|
||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||
'remark' => $requisition->remark,
|
||
];
|
||
|
||
// 手動更新屬性
|
||
$requisition->store_warehouse_id = $data['store_warehouse_id'];
|
||
$requisition->remark = $data['remark'] ?? null;
|
||
$requisition->reject_reason = null; // 清除駁回原因
|
||
|
||
// 品項對比邏輯
|
||
$oldItems = $requisition->items()->with('product:id,name')->get();
|
||
$oldItemsMap = $oldItems->keyBy('product_id');
|
||
$newItemsMap = collect($items)->keyBy('product_id');
|
||
|
||
$diff = [
|
||
'added' => [],
|
||
'removed' => [],
|
||
'updated' => [],
|
||
];
|
||
|
||
// 1. 處理更新與新增
|
||
foreach ($items as $itemData) {
|
||
$productId = $itemData['product_id'];
|
||
$newQty = (float)$itemData['requested_qty'];
|
||
$newRemark = $itemData['remark'] ?? null;
|
||
|
||
if ($oldItemsMap->has($productId)) {
|
||
$oldItem = $oldItemsMap->get($productId);
|
||
if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) {
|
||
$diff['updated'][] = [
|
||
'product_name' => $oldItem->product?->name ?? '未知商品',
|
||
'old' => [
|
||
'quantity' => (float)$oldItem->requested_qty,
|
||
'remark' => $oldItem->remark,
|
||
],
|
||
'new' => [
|
||
'quantity' => $newQty,
|
||
'remark' => $newRemark,
|
||
]
|
||
];
|
||
}
|
||
$oldItemsMap->forget($productId);
|
||
} else {
|
||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||
$diff['added'][] = [
|
||
'product_name' => $product?->name ?? '未知商品',
|
||
'new' => [
|
||
'quantity' => $newQty,
|
||
'remark' => $newRemark,
|
||
]
|
||
];
|
||
}
|
||
}
|
||
|
||
// 2. 處理移除
|
||
foreach ($oldItemsMap as $productId => $oldItem) {
|
||
$diff['removed'][] = [
|
||
'product_name' => $oldItem->product?->name ?? '未知商品',
|
||
'old' => [
|
||
'quantity' => (float)$oldItem->requested_qty,
|
||
'remark' => $oldItem->remark,
|
||
]
|
||
];
|
||
}
|
||
|
||
// 儲存實際變動
|
||
$requisition->items()->delete();
|
||
$itemsToInsert = [];
|
||
foreach ($items as $item) {
|
||
$itemsToInsert[] = [
|
||
'store_requisition_id' => $requisition->id,
|
||
'product_id' => $item['product_id'],
|
||
'requested_qty' => $item['requested_qty'],
|
||
'remark' => $item['remark'] ?? null,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
];
|
||
}
|
||
StoreRequisitionItem::insert($itemsToInsert);
|
||
|
||
// 檢查是否有任何變動 (主表或明細)
|
||
$isDirty = $requisition->isDirty();
|
||
$hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||
|
||
if ($isDirty || $hasItemsDiff) {
|
||
// 擷取新狀態
|
||
$newAttributes = [
|
||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||
'remark' => $requisition->remark,
|
||
];
|
||
|
||
// 靜默更新
|
||
$requisition->saveQuietly();
|
||
|
||
// 手動發送紀錄
|
||
activity()
|
||
->performedOn($requisition)
|
||
->event('updated')
|
||
->withProperties([
|
||
'items_diff' => $diff,
|
||
'attributes' => $newAttributes,
|
||
'old' => $oldAttributes
|
||
])
|
||
->log('updated');
|
||
}
|
||
|
||
return $requisition->load('items');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 提交審核(draft → pending)
|
||
*/
|
||
public function submit(StoreRequisition $requisition, int $userId): StoreRequisition
|
||
{
|
||
if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') {
|
||
throw ValidationException::withMessages([
|
||
'status' => '僅能提交草稿或被駁回的叫貨單',
|
||
]);
|
||
}
|
||
|
||
if ($requisition->items()->count() === 0) {
|
||
throw ValidationException::withMessages([
|
||
'items' => '叫貨單必須至少有一項商品',
|
||
]);
|
||
}
|
||
|
||
$requisition->update([
|
||
'status' => 'pending',
|
||
'submitted_at' => now(),
|
||
'reject_reason' => null,
|
||
]);
|
||
|
||
// 通知有審核權限的使用者
|
||
$this->notifyApprovers($requisition, 'submitted', $userId);
|
||
|
||
return $requisition;
|
||
}
|
||
|
||
/**
|
||
* 核准叫貨單(pending → approved),選擇供貨倉庫並自動產生調撥單
|
||
*/
|
||
public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition
|
||
{
|
||
if ($requisition->status !== 'pending') {
|
||
throw ValidationException::withMessages([
|
||
'status' => '僅能核准待審核的叫貨單',
|
||
]);
|
||
}
|
||
|
||
return DB::transaction(function () use ($requisition, $data, $userId) {
|
||
// 處理前端傳來的明細與批號資料
|
||
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
|
||
|
||
if (isset($data['items'])) {
|
||
$requisition->load('items.product');
|
||
$reqItemMap = $requisition->items->keyBy('id');
|
||
|
||
foreach ($data['items'] as $itemData) {
|
||
$reqItemId = $itemData['id'];
|
||
$reqItem = $reqItemMap->get($reqItemId);
|
||
$productName = $reqItem?->product?->name ?? '未知商品';
|
||
$totalApprovedQty = 0;
|
||
$batches = $itemData['batches'] ?? [];
|
||
|
||
// 如果有批號,根據批號展開。若有多個無批號(null)的批次(例如來自不同貨道),則將其數量加總
|
||
if (!empty($batches)) {
|
||
$batchGroups = [];
|
||
foreach ($batches as $batch) {
|
||
$qty = (float)($batch['qty'] ?? 0);
|
||
$bNum = $batch['batch_number'] ?? null;
|
||
$invId = $batch['inventory_id'] ?? null;
|
||
|
||
if ($qty > 0) {
|
||
if ($invId) {
|
||
$inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId);
|
||
if ($inventory) {
|
||
$available = max(0, $inventory->quantity - $inventory->reserved_quantity);
|
||
if ($qty > $available) {
|
||
$batchStr = $bNum ? "批號 {$bNum}" : "無批號";
|
||
throw ValidationException::withMessages([
|
||
'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})",
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
$totalApprovedQty += $qty;
|
||
$batchKey = $bNum ?? '';
|
||
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
|
||
}
|
||
}
|
||
|
||
foreach ($batchGroups as $bNumKey => $qty) {
|
||
$processedItems[] = [
|
||
'req_item_id' => $reqItemId,
|
||
'batch_number' => $bNumKey === '' ? null : $bNumKey,
|
||
'quantity' => $qty,
|
||
];
|
||
}
|
||
} else {
|
||
// 無批號,傳統輸入
|
||
$qty = (float)($itemData['approved_qty'] ?? 0);
|
||
if ($qty > 0) {
|
||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||
->where('product_id', $reqItem->product_id)
|
||
->lockForUpdate() // 補上鎖定
|
||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||
->value('available') ?? 0;
|
||
|
||
if ($qty > $totalAvailable) {
|
||
throw ValidationException::withMessages([
|
||
'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})",
|
||
]);
|
||
}
|
||
|
||
$totalApprovedQty += $qty;
|
||
$processedItems[] = [
|
||
'req_item_id' => $reqItemId,
|
||
'batch_number' => null,
|
||
'quantity' => $qty,
|
||
];
|
||
}
|
||
}
|
||
|
||
// 更新叫貨單明細的核准數量總和
|
||
StoreRequisitionItem::where('id', $reqItemId)
|
||
->where('store_requisition_id', $requisition->id)
|
||
->update(['approved_qty' => $totalApprovedQty]);
|
||
}
|
||
}
|
||
|
||
// 優先使用傳入的供貨倉庫,若無則從單據中取得
|
||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||
|
||
if (!$supplyWarehouseId) {
|
||
throw ValidationException::withMessages([
|
||
'supply_warehouse_id' => '請指定供貨倉庫',
|
||
]);
|
||
}
|
||
|
||
// 查詢供貨倉庫是否有預設在途倉
|
||
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($supplyWarehouseId);
|
||
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
|
||
|
||
// 產生調撥單(供貨倉庫 → 門市倉庫)
|
||
$transferOrder = $this->transferService->createOrder(
|
||
fromWarehouseId: $supplyWarehouseId,
|
||
toWarehouseId: $requisition->store_warehouse_id,
|
||
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
|
||
userId: $userId,
|
||
transitWarehouseId: $defaultTransitId,
|
||
);
|
||
|
||
// 將核准的明細寫入調撥單
|
||
$requisition->load('items');
|
||
$transferItems = [];
|
||
|
||
// 建立 req_item_id 對應 product_id 的 lookup
|
||
$reqItemMap = $requisition->items->keyBy('id');
|
||
|
||
foreach ($processedItems as $pItem) {
|
||
$reqItem = $reqItemMap->get($pItem['req_item_id']);
|
||
if ($reqItem) {
|
||
$transferItems[] = [
|
||
'product_id' => $reqItem->product_id,
|
||
'batch_number' => $pItem['batch_number'],
|
||
'quantity' => $pItem['quantity'],
|
||
];
|
||
}
|
||
}
|
||
|
||
if (!empty($transferItems)) {
|
||
$this->transferService->updateItems($transferOrder, $transferItems);
|
||
|
||
// 手動發送調撥單的「已建立」合併日誌,包含初始明細
|
||
activity()
|
||
->performedOn($transferOrder)
|
||
->causedBy($userId)
|
||
->event('created')
|
||
->withProperties(array_merge(
|
||
['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []],
|
||
[
|
||
'attributes' => [
|
||
'doc_no' => $transferOrder->doc_no,
|
||
'from_warehouse_id' => $transferOrder->from_warehouse_id,
|
||
'to_warehouse_id' => $transferOrder->to_warehouse_id,
|
||
'transit_warehouse_id' => $transferOrder->transit_warehouse_id,
|
||
'remarks' => $transferOrder->remarks,
|
||
'status' => $transferOrder->status,
|
||
'created_by' => $transferOrder->created_by,
|
||
]
|
||
]
|
||
))
|
||
->log('created');
|
||
}
|
||
|
||
// 更新叫貨單狀態
|
||
$requisition->update([
|
||
'status' => 'approved',
|
||
'supply_warehouse_id' => $supplyWarehouseId,
|
||
'approved_by' => $userId,
|
||
'approved_at' => now(),
|
||
'transfer_order_id' => $transferOrder->id,
|
||
]);
|
||
|
||
// 通知申請人
|
||
$this->notifyCreator($requisition, 'approved', $userId);
|
||
|
||
return $requisition->load(['items', 'transferOrder']);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 駁回叫貨單(pending → rejected)
|
||
*/
|
||
public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition
|
||
{
|
||
if ($requisition->status !== 'pending') {
|
||
throw ValidationException::withMessages([
|
||
'status' => '僅能駁回待審核的叫貨單',
|
||
]);
|
||
}
|
||
|
||
$requisition->update([
|
||
'status' => 'rejected',
|
||
'reject_reason' => $reason,
|
||
'approved_by' => $userId,
|
||
'approved_at' => now(),
|
||
]);
|
||
|
||
// 通知申請人
|
||
$this->notifyCreator($requisition, 'rejected', $userId);
|
||
|
||
return $requisition;
|
||
}
|
||
|
||
/**
|
||
* 取消叫貨單
|
||
*/
|
||
public function cancel(StoreRequisition $requisition): StoreRequisition
|
||
{
|
||
if (!in_array($requisition->status, ['draft', 'pending'])) {
|
||
throw ValidationException::withMessages([
|
||
'status' => '僅能取消草稿或待審核的叫貨單',
|
||
]);
|
||
}
|
||
|
||
$requisition->update(['status' => 'cancelled']);
|
||
|
||
return $requisition;
|
||
}
|
||
|
||
/**
|
||
* 通知有審核權限的使用者
|
||
*/
|
||
protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void
|
||
{
|
||
$actor = User::find($actorId);
|
||
$actorName = $actor?->name ?? 'System';
|
||
|
||
// 找出有 store_requisitions.approve 權限的使用者
|
||
$approvers = User::permission('store_requisitions.approve')->get();
|
||
|
||
foreach ($approvers as $approver) {
|
||
if ($approver->id !== $actorId) {
|
||
$approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通知叫貨單申請人
|
||
*/
|
||
protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void
|
||
{
|
||
$actor = User::find($actorId);
|
||
$actorName = $actor?->name ?? 'System';
|
||
|
||
$creator = User::find($requisition->created_by);
|
||
if ($creator && $creator->id !== $actorId) {
|
||
$creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||
}
|
||
}
|
||
}
|