feat: 優化庫存調撥單操作紀錄與 UI 佈局

This commit is contained in:
2026-02-04 17:51:29 +08:00
parent 2eb136d280
commit 4299e985e9
6 changed files with 244 additions and 128 deletions

View File

@@ -30,6 +30,7 @@ class ActivityLogController extends Controller
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項', 'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單', 'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單', 'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
]; ];
} }
@@ -83,6 +84,7 @@ class ActivityLogController extends Controller
} }
$activities = $query->paginate($perPage) $activities = $query->paginate($perPage)
->withQueryString()
->through(function ($activity) { ->through(function ($activity) {
$subjectMap = $this->getSubjectMap(); $subjectMap = $this->getSubjectMap();

View File

@@ -82,51 +82,10 @@ class TransferOrderController extends Controller
auth()->id() 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) { if ($request->input('instant_post') === true) {
try { try {
$this->transferService->post($order, auth()->id()); $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', '撥補成功,庫存已更新'); return redirect()->back()->with('success', '撥補成功,庫存已更新');
} catch (\Exception $e) { } catch (\Exception $e) {
// 如果過帳失敗,雖然單據已建立,但應回報錯誤 // 如果過帳失敗,雖然單據已建立,但應回報錯誤
@@ -185,60 +144,35 @@ class TransferOrderController extends Controller
return redirect()->back()->with('error', '只能修改草稿狀態的單據'); 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([ $validated = $request->validate([
'items' => 'array', 'items' => 'array',
'items.*.product_id' => 'required|exists:products,id', 'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batch_number' => 'nullable|string', 'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string', 'items.*.notes' => 'nullable|string',
'remarks' => 'nullable|string',
]); ]);
// 1. 先更新資料
if ($request->has('items')) { if ($request->has('items')) {
$this->transferService->updateItems($order, $validated['items']); $this->transferService->updateItems($order, $validated['items']);
} }
$order->update($request->only(['remarks'])); $order->fill($request->only(['remarks']));
// 記錄暫存活動 // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
activity() $order->touch();
->performedOn($order)
->causedBy(auth()->user()) // 2. 判斷是否需要過帳
->event('updated') if ($request->input('action') === 'post') {
->withProperties([ try {
'snapshot' => [ $this->transferService->post($order, auth()->id());
'doc_no' => $order->doc_no, return redirect()->route('inventory.transfer.index')
'from_warehouse_name' => $order->fromWarehouse?->name, ->with('success', '調撥單已過帳完成');
'to_warehouse_name' => $order->toWarehouse?->name, } catch (\Exception $e) {
] return redirect()->back()->withErrors(['items' => $e->getMessage()]);
]) }
->log('updated_items'); }
return redirect()->back()->with('success', '儲存成功'); return redirect()->back()->with('success', '儲存成功');
} }
@@ -249,20 +183,6 @@ class TransferOrderController extends Controller
return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); 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->items()->delete();
$order->delete(); $order->delete();

View File

@@ -1,16 +1,106 @@
<?php <?php
namespace App\Modules\Inventory\Models; namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Core\Models\User; use App\Modules\Core\Models\User;
class InventoryTransferOrder extends Model class InventoryTransferOrder extends Model
{ {
use HasFactory; use HasFactory, LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->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 = [ protected $fillable = [
'doc_no', 'doc_no',

View File

@@ -28,18 +28,94 @@ class TransferService
/** /**
* 更新調撥單明細 * 更新調撥單明細
*/ */
/**
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
*/
public function updateItems(InventoryTransferOrder $order, array $itemsData): void public function updateItems(InventoryTransferOrder $order, array $itemsData): void
{ {
DB::transaction(function () use ($order, $itemsData) { 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(); $order->items()->delete();
$newItemsKeys = [];
foreach ($itemsData as $data) { 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'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'notes' => $data['notes'] ?? null, '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 public function post(InventoryTransferOrder $order, int $userId): void
{ {
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems導致記憶體中快取的 items 是舊的或空的
$order->load('items.product');
DB::transaction(function () use ($order, $userId) { DB::transaction(function () use ($order, $userId) {
$fromWarehouse = $order->fromWarehouse; $fromWarehouse = $order->fromWarehouse;
$toWarehouse = $order->toWarehouse; $toWarehouse = $order->toWarehouse;
@@ -131,11 +210,25 @@ class TransferService
]); ]);
} }
$order->update([ // 準備品項快照供日誌使用
'status' => 'completed', $itemsSnapshot = $order->items->map(function($item) {
'posted_at' => now(), return [
'posted_by' => $userId, '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(); // 觸發自動日誌
}); });
} }

View File

@@ -171,6 +171,8 @@ const statusMap: Record<string, string> = {
// 生產工單狀態 // 生產工單狀態
planned: '已計畫', planned: '已計畫',
in_progress: '生產中', in_progress: '生產中',
// 調撥單狀態
voided: '已作廢',
// completed 已定義 // completed 已定義
}; };
@@ -223,12 +225,21 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return a.localeCompare(b); return a.localeCompare(b);
}); });
// 檢查鍵是否為快照名稱欄位的輔助函式 // 檢查鍵是否為快照名稱欄位或輔助名稱欄位的輔助函式
const isSnapshotField = (key: string) => { const isSnapshotField = (key: string) => {
return [ // 隱藏快照欄位
const snapshotFields = [
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name', 'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
'warehouse_name', 'user_name' 'warehouse_name', 'user_name', 'from_warehouse_name', 'to_warehouse_name',
].includes(key); '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) => { const getEventBadgeClass = (event: string) => {
@@ -343,7 +354,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-0 gap-0">
<DialogHeader className="p-6 pb-4 border-b pr-12"> <DialogHeader className="p-6 pb-4 border-b pr-12">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<DialogTitle className="text-xl font-bold text-gray-900"> <DialogTitle className="text-xl font-bold text-gray-900">
@@ -385,12 +396,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
<div className="bg-gray-50/50 p-6 min-h-[300px]"> <div className="bg-gray-50/50 p-6 min-h-[300px]">
{activity.event === 'created' ? ( {activity.event === 'created' ? (
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50"> <TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -398,9 +409,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
.map((key) => ( .map((key) => (
<TableRow key={key}> <TableRow key={key}>
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell> <TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell> <TableCell className="text-gray-500">-</TableCell>
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap"> <TableCell className="text-gray-900 font-medium break-all whitespace-pre-wrap">
{getFormattedValue(key, attributes[key])} {getFormattedValue(key, attributes[key])}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -417,12 +428,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</div> </div>
) : ( ) : (
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50"> <TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -456,11 +467,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return ( return (
<TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}> <TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}>
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell> <TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap"> <TableCell className="text-gray-500 break-all whitespace-pre-wrap">
{displayBefore} {displayBefore}
</TableCell> </TableCell>
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap"> <TableCell className="text-gray-900 font-medium break-all whitespace-pre-wrap">
{displayAfter} {displayAfter}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -486,12 +497,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</h3> </h3>
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader className="bg-gray-50/50"> <TableHeader className="bg-gray-50/50">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead className="w-1/3"></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="w-[100px] text-center"></TableHead>
<TableHead> ( )</TableHead> <TableHead className="w-1/2"> ( )</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -113,7 +113,7 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
setPerPage(value); setPerPage(value);
router.get( router.get(
route('activity-logs.index'), route('activity-logs.index'),
{ ...filters, per_page: value }, { ...filters, per_page: value, page: 1 },
{ preserveState: false, replace: true, preserveScroll: true } { preserveState: false, replace: true, preserveScroll: true }
); );
}; };