From 4299e985e93d6f63edb13741c0bd7e68bd618b8b Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 4 Feb 2026 17:51:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=84=AA=E5=8C=96=E5=BA=AB=E5=AD=98?= =?UTF-8?q?=E8=AA=BF=E6=92=A5=E5=96=AE=E6=93=8D=E4=BD=9C=E7=B4=80=E9=8C=84?= =?UTF-8?q?=E8=88=87=20UI=20=E4=BD=88=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ActivityLogController.php | 2 + .../Controllers/TransferOrderController.php | 112 +++--------------- .../Models/InventoryTransferOrder.php | 94 ++++++++++++++- .../Inventory/Services/TransferService.php | 105 +++++++++++++++- .../ActivityLog/ActivityDetailDialog.tsx | 57 +++++---- .../js/Pages/Admin/ActivityLog/Index.tsx | 2 +- 6 files changed, 244 insertions(+), 128 deletions(-) diff --git a/app/Modules/Core/Controllers/ActivityLogController.php b/app/Modules/Core/Controllers/ActivityLogController.php index fc6fc04..b7a8011 100644 --- a/app/Modules/Core/Controllers/ActivityLogController.php +++ b/app/Modules/Core/Controllers/ActivityLogController.php @@ -30,6 +30,7 @@ class ActivityLogController extends Controller 'App\Modules\Production\Models\ProductionOrderItem' => '工單品項', 'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單', 'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單', + 'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單', ]; } @@ -83,6 +84,7 @@ class ActivityLogController extends Controller } $activities = $query->paginate($perPage) + ->withQueryString() ->through(function ($activity) { $subjectMap = $this->getSubjectMap(); diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index f8c3dd6..5c1c493 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -82,50 +82,9 @@ class TransferOrderController extends Controller auth()->id() ); - // 記錄活動 - activity() - ->performedOn($order) - ->causedBy(auth()->user()) - ->event('created') - ->withProperties([ - 'attributes' => $order->toArray(), - 'snapshot' => [ - 'doc_no' => $order->doc_no, - 'from_warehouse_name' => $order->fromWarehouse?->name, - 'to_warehouse_name' => $order->toWarehouse?->name, - ] - ]) - ->log('created'); - - // 如果請求包含單筆商品資訊 - if ($request->has('product_id')) { - $this->transferService->updateItems($order, [[ - 'product_id' => $validated['product_id'], - 'quantity' => $validated['quantity'], - 'batch_number' => $validated['batch_number'] ?? null, - ]]); - } - - // 如果是撥補單,執行直接過帳 if ($request->input('instant_post') === true) { try { $this->transferService->post($order, auth()->id()); - - // 記錄過帳活動 - activity() - ->performedOn($order) - ->causedBy(auth()->user()) - ->event('posted') - ->withProperties([ - 'attributes' => ['status' => 'posted'], - 'old' => ['status' => 'draft'], - 'snapshot' => [ - 'doc_no' => $order->doc_no, - 'from_warehouse_name' => $order->fromWarehouse?->name, - 'to_warehouse_name' => $order->toWarehouse?->name, - ] - ]) - ->log('posted'); return redirect()->back()->with('success', '撥補成功,庫存已更新'); } catch (\Exception $e) { @@ -185,60 +144,35 @@ class TransferOrderController extends Controller return redirect()->back()->with('error', '只能修改草稿狀態的單據'); } - if ($request->input('action') === 'post') { - try { - $this->transferService->post($order, auth()->id()); - - // 記錄活動 - activity() - ->performedOn($order) - ->causedBy(auth()->user()) - ->event('posted') - ->withProperties([ - 'attributes' => ['status' => 'posted'], - 'old' => ['status' => 'draft'], - 'snapshot' => [ - 'doc_no' => $order->doc_no, - 'from_warehouse_name' => $order->fromWarehouse?->name, - 'to_warehouse_name' => $order->toWarehouse?->name, - ] - ]) - ->log('posted'); - - return redirect()->route('inventory.transfer.index') - ->with('success', '調撥單已過帳完成'); - } catch (\Exception $e) { - return redirect()->back()->withErrors(['items' => $e->getMessage()]); - } - } - $validated = $request->validate([ 'items' => 'array', 'items.*.product_id' => 'required|exists:products,id', 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.batch_number' => 'nullable|string', 'items.*.notes' => 'nullable|string', + 'remarks' => 'nullable|string', ]); + // 1. 先更新資料 if ($request->has('items')) { $this->transferService->updateItems($order, $validated['items']); } - $order->update($request->only(['remarks'])); + $order->fill($request->only(['remarks'])); + + // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 + $order->touch(); - // 記錄暫存活動 - activity() - ->performedOn($order) - ->causedBy(auth()->user()) - ->event('updated') - ->withProperties([ - 'snapshot' => [ - 'doc_no' => $order->doc_no, - 'from_warehouse_name' => $order->fromWarehouse?->name, - 'to_warehouse_name' => $order->toWarehouse?->name, - ] - ]) - ->log('updated_items'); + // 2. 判斷是否需要過帳 + if ($request->input('action') === 'post') { + try { + $this->transferService->post($order, auth()->id()); + return redirect()->route('inventory.transfer.index') + ->with('success', '調撥單已過帳完成'); + } catch (\Exception $e) { + return redirect()->back()->withErrors(['items' => $e->getMessage()]); + } + } return redirect()->back()->with('success', '儲存成功'); } @@ -249,20 +183,6 @@ class TransferOrderController extends Controller return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); } - // 記錄活動 - activity() - ->performedOn($order) - ->causedBy(auth()->user()) - ->event('deleted') - ->withProperties([ - 'snapshot' => [ - 'doc_no' => $order->doc_no, - 'from_warehouse_name' => $order->fromWarehouse?->name, - 'to_warehouse_name' => $order->toWarehouse?->name, - ] - ]) - ->log('deleted'); - $order->items()->delete(); $order->delete(); diff --git a/app/Modules/Inventory/Models/InventoryTransferOrder.php b/app/Modules/Inventory/Models/InventoryTransferOrder.php index f6cb0c7..ca11db3 100644 --- a/app/Modules/Inventory/Models/InventoryTransferOrder.php +++ b/app/Modules/Inventory/Models/InventoryTransferOrder.php @@ -1,16 +1,106 @@ logFillable() + ->dontSubmitEmptyLogs(); + } + + /** + * @var array 暫存的活動紀錄屬性 (不會存入資料庫) + */ + public $activityProperties = []; + + /** + * 自定義日誌屬性名稱解析 + */ + public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName) + { + $properties = $activity->properties->toArray(); + + // 處置日誌事件說明 + if ($eventName === 'created') { + $activity->description = 'created'; + } elseif ($eventName === 'updated') { + // 如果屬性中有 status 且變更為 completed,將描述改為 posted + if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') { + $activity->description = 'posted'; + $eventName = 'posted'; // 供後續快照邏輯判定 + } else { + $activity->description = 'updated'; + } + } + + // 處理倉庫 ID 轉名稱 + $idToNameFields = [ + 'from_warehouse_id' => 'fromWarehouse', + 'to_warehouse_id' => 'toWarehouse', + 'created_by' => 'createdBy', + 'posted_by' => 'postedBy', + ]; + + foreach (['attributes', 'old'] as $part) { + if (isset($properties[$part])) { + foreach ($idToNameFields as $idField => $relation) { + if (isset($properties[$part][$idField])) { + $id = $properties[$part][$idField]; + $nameField = str_replace('_id', '_name', $idField); + + $name = null; + if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) { + $name = $this->$relation->name; + } else { + $model = $this->$relation()->getRelated()->find($id); + $name = $model ? $model->name : "ID: $id"; + } + $properties[$part][$nameField] = $name; + } + } + } + } + + // 基本單據資訊快照 (包含單號、來源、目的地) + if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) { + $properties['snapshot'] = [ + 'doc_no' => $this->doc_no, + 'from_warehouse_name' => $this->fromWarehouse?->name, + 'to_warehouse_name' => $this->toWarehouse?->name, + 'status' => $this->status, + ]; + } + + // 移除輔助欄位與雜訊 + if (isset($properties['attributes'])) { + unset($properties['attributes']['from_warehouse_name']); + unset($properties['attributes']['to_warehouse_name']); + unset($properties['attributes']['activityProperties']); + unset($properties['attributes']['updated_at']); + } + if (isset($properties['old'])) { + unset($properties['old']['updated_at']); + } + + // 合併暫存屬性 (例如 items_diff) + if (!empty($this->activityProperties)) { + $properties = array_merge($properties, $this->activityProperties); + } + + $activity->properties = collect($properties); + } protected $fillable = [ 'doc_no', diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index 336194f..6a0bb16 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -28,18 +28,94 @@ class TransferService /** * 更新調撥單明細 */ + /** + * 更新調撥單明細 (支援精確 Diff 與自動日誌整合) + */ public function updateItems(InventoryTransferOrder $order, array $itemsData): void { DB::transaction(function () use ($order, $itemsData) { + // 1. 準備舊資料索引 (Key: product_id . '_' . batch_number) + $oldItemsMap = $order->items->mapWithKeys(function ($item) { + $key = $item->product_id . '_' . ($item->batch_number ?? ''); + return [$key => $item]; + }); + + $diff = [ + 'added' => [], + 'removed' => [], + 'updated' => [], + ]; + + // 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update) + // 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的,create 新的,delete 舊的。 + // 但考量現有邏輯是 delete all -> create all,我們維持原策略但優化 Diff 計算。 + + // 由於採用全刪重建,我們必須手動計算 Diff $order->items()->delete(); + $newItemsKeys = []; + foreach ($itemsData as $data) { - $order->items()->create([ + $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'], 'notes' => $data['notes'] ?? null, ]); + // Eager load product for name + $item->load('product'); + + // 比對邏輯 + if ($oldItemsMap->has($key)) { + $oldItem = $oldItemsMap->get($key); + // 檢查數值是否有變動 + if ((float)$oldItem->quantity !== (float)$data['quantity'] || + $oldItem->notes !== ($data['notes'] ?? null)) { + + $diff['updated'][] = [ + 'product_name' => $item->product->name, + 'old' => [ + 'quantity' => (float)$oldItem->quantity, + 'notes' => $oldItem->notes, + ], + 'new' => [ + 'quantity' => (float)$data['quantity'], + 'notes' => $item->notes, + ] + ]; + } + } else { + // 新增 + $diff['added'][] = [ + 'product_name' => $item->product->name, + 'new' => [ + 'quantity' => (float)$item->quantity, + 'notes' => $item->notes, + ] + ]; + } + } + + // 3. 處理被移除的項目 + 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, + ] + ]; + } + } + + // 4. 將 Diff 注入到 Model 的暫存屬性中 + // 如果 Diff 有內容,才注入 + if (!empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated'])) { + $order->activityProperties['items_diff'] = $diff; } }); } @@ -49,6 +125,9 @@ class TransferService */ public function post(InventoryTransferOrder $order, int $userId): void { + // [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems,導致記憶體中快取的 items 是舊的或空的 + $order->load('items.product'); + DB::transaction(function () use ($order, $userId) { $fromWarehouse = $order->fromWarehouse; $toWarehouse = $order->toWarehouse; @@ -131,11 +210,25 @@ class TransferService ]); } - $order->update([ - 'status' => 'completed', - 'posted_at' => now(), - 'posted_by' => $userId, - ]); + // 準備品項快照供日誌使用 + $itemsSnapshot = $order->items->map(function($item) { + return [ + 'product_name' => $item->product->name, + 'old' => [ + 'quantity' => (float)$item->quantity, + 'notes' => $item->notes, + ], + 'new' => [ + 'quantity' => (float)$item->quantity, + 'notes' => $item->notes, + ] + ]; + })->toArray(); + + $order->status = 'completed'; + $order->posted_at = now(); + $order->posted_by = $userId; + $order->save(); // 觸發自動日誌 }); } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 2e220ec..e7b0e4c 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -171,6 +171,8 @@ const statusMap: Record = { // 生產工單狀態 planned: '已計畫', in_progress: '生產中', + // 調撥單狀態 + voided: '已作廢', // completed 已定義 }; @@ -223,12 +225,21 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return a.localeCompare(b); }); - // 檢查鍵是否為快照名稱欄位的輔助函式 + // 檢查鍵是否為快照名稱欄位或輔助名稱欄位的輔助函式 const isSnapshotField = (key: string) => { - return [ + // 隱藏快照欄位 + const snapshotFields = [ 'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name', - 'warehouse_name', 'user_name' - ].includes(key); + 'warehouse_name', 'user_name', 'from_warehouse_name', 'to_warehouse_name', + 'created_by_name', 'updated_by_name', 'completed_by_name', 'posted_by_name' + ]; + + if (snapshotFields.includes(key)) return true; + + // 隱藏所有以 _name 結尾的欄位(因為它們通常是 ID 欄位的文字補充) + if (key.endsWith('_name')) return true; + + return false; }; const getEventBadgeClass = (event: string) => { @@ -343,7 +354,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return ( - +
@@ -385,12 +396,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
{activity.event === 'created' ? (
- +
- 欄位 - 異動前 - 異動後 + 欄位 + 異動前 + 異動後 @@ -398,9 +409,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) .map((key) => ( - {getFieldLabel(key)} - - - + {getFieldLabel(key)} + - + {getFormattedValue(key, attributes[key])} @@ -417,12 +428,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P ) : (
-
+
- 欄位 - 異動前 - 異動後 + 欄位 + 異動前 + 異動後 @@ -456,11 +467,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return ( - {getFieldLabel(key)} - + {getFieldLabel(key)} + {displayBefore} - + {displayAfter} @@ -486,12 +497,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
-
+
- 商品名稱 - 異動類型 - 異動詳情 (舊 → 新) + 商品名稱 + 異動類型 + 異動詳情 (舊 → 新) diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx index f8a3cab..01c2248 100644 --- a/resources/js/Pages/Admin/ActivityLog/Index.tsx +++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx @@ -113,7 +113,7 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u setPerPage(value); router.get( route('activity-logs.index'), - { ...filters, per_page: value }, + { ...filters, per_page: value, page: 1 }, { preserveState: false, replace: true, preserveScroll: true } ); };