diff --git a/app/Modules/Core/Controllers/ActivityLogController.php b/app/Modules/Core/Controllers/ActivityLogController.php index ae8dc47..fc6fc04 100644 --- a/app/Modules/Core/Controllers/ActivityLogController.php +++ b/app/Modules/Core/Controllers/ActivityLogController.php @@ -29,6 +29,7 @@ class ActivityLogController extends Controller 'App\Modules\Production\Models\RecipeItem' => '配方品項', 'App\Modules\Production\Models\ProductionOrderItem' => '工單品項', 'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單', + 'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單', ]; } diff --git a/app/Modules/Inventory/Controllers/AdjustDocController.php b/app/Modules/Inventory/Controllers/AdjustDocController.php index 2cf59e1..261a703 100644 --- a/app/Modules/Inventory/Controllers/AdjustDocController.php +++ b/app/Modules/Inventory/Controllers/AdjustDocController.php @@ -69,6 +69,12 @@ class AdjustDocController extends Controller // 模式 1: 從盤點單建立 if ($request->filled('count_doc_id')) { $countDoc = InventoryCountDoc::findOrFail($request->count_doc_id); + if ($countDoc->status !== 'completed') { + $errorMsg = $countDoc->status === 'no_adjust' + ? '此盤點單無庫存差異,無需建立盤調單' + : '只有已完成盤點的單據可以建立盤調單'; + return redirect()->back()->with('error', $errorMsg); + } // 檢查是否已存在對應的盤調單 (避免重複建立) if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) { @@ -76,21 +82,6 @@ class AdjustDocController extends Controller } $doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id()); - - // 記錄活動 - activity() - ->performedOn($doc) - ->causedBy(auth()->user()) - ->event('created') - ->withProperties([ - 'attributes' => $doc->toArray(), - 'snapshot' => [ - 'doc_no' => $doc->doc_no, - 'warehouse_name' => $doc->warehouse?->name, - 'count_doc_no' => $countDoc->doc_no, - ] - ]) - ->log('created_from_count'); return redirect()->route('inventory.adjust.show', [$doc->id]) ->with('success', '已從盤點單生成盤調單'); @@ -143,6 +134,49 @@ class AdjustDocController extends Controller return response()->json($counts); } + public function update(Request $request, InventoryAdjustDoc $doc) + { + $action = $request->input('action', 'update'); + + if ($action === 'post') { + if ($doc->status !== 'draft') { + return redirect()->back()->with('error', '只有草稿狀態的單據可以過帳'); + } + $this->adjustService->post($doc, auth()->id()); + return redirect()->back()->with('success', '單據已過帳'); + } + + if ($action === 'void') { + if ($doc->status !== 'draft') { + return redirect()->back()->with('error', '只有草稿狀態的單據可以作廢'); + } + $this->adjustService->void($doc, auth()->id()); + return redirect()->back()->with('success', '單據已作廢'); + } + + // 一般更新 (更新品項與基本資訊) + if ($doc->status !== 'draft') { + return redirect()->back()->with('error', '只有草稿狀態的單據可以修改'); + } + + $request->validate([ + 'reason' => 'required|string', + 'remarks' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required', + 'items.*.adjust_qty' => 'required|numeric', + ]); + + $doc->update([ + 'reason' => $request->reason, + 'remarks' => $request->remarks, + ]); + + $this->adjustService->updateItems($doc, $request->items); + + return redirect()->back()->with('success', '單據已更新'); + } + public function show(InventoryAdjustDoc $doc) { $doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']); @@ -185,18 +219,7 @@ class AdjustDocController extends Controller return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); } - // 記錄活動 - activity() - ->performedOn($doc) - ->causedBy(auth()->user()) - ->event('deleted') - ->withProperties([ - 'snapshot' => [ - 'doc_no' => $doc->doc_no, - 'warehouse_name' => $doc->warehouse?->name, - ] - ]) - ->log('deleted'); + $doc->items()->delete(); $doc->delete(); diff --git a/app/Modules/Inventory/Controllers/CountDocController.php b/app/Modules/Inventory/Controllers/CountDocController.php index 8dfbd7e..f40dec0 100644 --- a/app/Modules/Inventory/Controllers/CountDocController.php +++ b/app/Modules/Inventory/Controllers/CountDocController.php @@ -192,8 +192,8 @@ class CountDocController extends Controller abort(403); } - if ($doc->status !== 'completed') { - return redirect()->back()->with('error', '僅能針對已完成的盤點單重新開啟盤點'); + if (!in_array($doc->status, ['completed', 'no_adjust'])) { + return redirect()->back()->with('error', '僅能針對已完成或無需盤調的盤點單重新開啟盤點'); } // 執行取消核准邏輯 diff --git a/app/Modules/Inventory/Models/InventoryAdjustDoc.php b/app/Modules/Inventory/Models/InventoryAdjustDoc.php index 87ebc5c..3dc1ebb 100644 --- a/app/Modules/Inventory/Models/InventoryAdjustDoc.php +++ b/app/Modules/Inventory/Models/InventoryAdjustDoc.php @@ -8,9 +8,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use App\Modules\Core\Models\User; +use Spatie\Activitylog\Traits\LogsActivity; +use Spatie\Activitylog\LogOptions; + class InventoryAdjustDoc extends Model { - use HasFactory; + use HasFactory, LogsActivity; protected $fillable = [ 'doc_no', @@ -78,4 +81,64 @@ class InventoryAdjustDoc extends Model { return $this->belongsTo(User::class, 'posted_by'); } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + // 確保為陣列以進行修改 + $properties = $activity->properties instanceof \Illuminate\Support\Collection + ? $activity->properties->toArray() + : $activity->properties; + + $snapshot = $properties['snapshot'] ?? []; + + // Snapshot key information + $snapshot['doc_no'] = $this->doc_no; + $snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null; + $snapshot['posted_at'] = $this->posted_at ? $this->posted_at->format('Y-m-d H:i:s') : null; + $snapshot['status'] = $this->status; + $snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null; + $snapshot['posted_by_name'] = $this->postedBy ? $this->postedBy->name : null; + + $properties['snapshot'] = $snapshot; + + // 全域 ID 轉名稱邏輯 (用於 attributes 與 old) + $convertIdsToNames = function (&$data) { + if (empty($data) || !is_array($data)) return; + + // 倉庫 ID 轉換 + if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) { + $warehouse = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id']); + if ($warehouse) { + $data['warehouse_id'] = $warehouse->name; + } + } + + // 使用者 ID 轉換 + $userFields = ['created_by', 'updated_by', 'posted_by']; + foreach ($userFields as $field) { + if (isset($data[$field]) && is_numeric($data[$field])) { + $user = \App\Modules\Core\Models\User::find($data[$field]); + if ($user) { + $data[$field] = $user->name; + } + } + } + }; + + if (isset($properties['attributes'])) { + $convertIdsToNames($properties['attributes']); + } + if (isset($properties['old'])) { + $convertIdsToNames($properties['old']); + } + + $activity->properties = $properties; + } } diff --git a/app/Modules/Inventory/Models/InventoryCountDoc.php b/app/Modules/Inventory/Models/InventoryCountDoc.php index 92386ea..dd919e1 100644 --- a/app/Modules/Inventory/Models/InventoryCountDoc.php +++ b/app/Modules/Inventory/Models/InventoryCountDoc.php @@ -82,8 +82,7 @@ class InventoryCountDoc extends Model public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions { return \Spatie\Activitylog\LogOptions::defaults() - ->logAll() - ->logOnlyDirty() + ->logFillable() ->dontSubmitEmptyLogs(); } diff --git a/app/Modules/Inventory/Services/AdjustService.php b/app/Modules/Inventory/Services/AdjustService.php index d4d5cd6..b5410ef 100644 --- a/app/Modules/Inventory/Services/AdjustService.php +++ b/app/Modules/Inventory/Services/AdjustService.php @@ -60,6 +60,21 @@ class AdjustService public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void { DB::transaction(function () use ($doc, $itemsData) { + $updatedItems = []; + $oldItems = $doc->items()->with('product')->get(); + + // 記錄舊品項狀態 (用於標註異動) + foreach ($oldItems as $oldItem) { + $updatedItems[] = [ + 'product_name' => $oldItem->product->name, + 'old' => [ + 'adjust_qty' => (float)$oldItem->adjust_qty, + 'notes' => $oldItem->notes, + ], + 'new' => null // 標記為刪除或待更新 + ]; + } + $doc->items()->delete(); foreach ($itemsData as $data) { @@ -71,13 +86,60 @@ class AdjustService $qtyBefore = $inventory ? $inventory->quantity : 0; - $doc->items()->create([ + $newItem = $doc->items()->create([ 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'qty_before' => $qtyBefore, 'adjust_qty' => $data['adjust_qty'], 'notes' => $data['notes'] ?? null, ]); + + // 更新日誌中的品項列表 + $productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name; + $found = false; + foreach ($updatedItems as $idx => $ui) { + if ($ui['product_name'] === $productName && $ui['new'] === null) { + $updatedItems[$idx]['new'] = [ + 'adjust_qty' => (float)$data['adjust_qty'], + 'notes' => $data['notes'] ?? null, + ]; + $found = true; + break; + } + } + if (!$found) { + $updatedItems[] = [ + 'product_name' => $productName, + 'old' => null, + 'new' => [ + 'adjust_qty' => (float)$data['adjust_qty'], + 'notes' => $data['notes'] ?? null, + ] + ]; + } + } + + // 清理沒被更新到的舊品項 (即真正被刪除的) + $finalUpdatedItems = []; + foreach ($updatedItems as $ui) { + if ($ui['old'] === null && $ui['new'] === null) continue; + // 比對是否有實質變動 + if ($ui['old'] != $ui['new']) { + $finalUpdatedItems[] = $ui; + } + } + + if (!empty($finalUpdatedItems)) { + activity() + ->performedOn($doc) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'items_diff' => [ + 'updated' => $finalUpdatedItems, + ] + ]) + ->log('updated'); } }); } @@ -88,13 +150,11 @@ class AdjustService public function post(InventoryAdjustDoc $doc, int $userId): void { DB::transaction(function () use ($doc, $userId) { + $oldStatus = $doc->status; + foreach ($doc->items as $item) { if ($item->adjust_qty == 0) continue; - // 找尋或建立 Inventory - // 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管) - // 若是增加庫存,若不存在需建立 - $inventory = Inventory::firstOrNew([ 'warehouse_id' => $doc->warehouse_id, 'product_id' => $item->product_id, @@ -103,7 +163,6 @@ class AdjustService // 如果是新建立的 object (id 為空),需要初始化 default if (!$inventory->exists) { - // 繼承 Product 成本或預設 0 (簡化處理) $inventory->unit_cost = $item->product->cost ?? 0; $inventory->quantity = 0; } @@ -112,7 +171,6 @@ class AdjustService $newQty = $oldQty + $item->adjust_qty; $inventory->quantity = $newQty; - // 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權) $inventory->total_value = $newQty * $inventory->unit_cost; $inventory->save(); @@ -129,17 +187,53 @@ class AdjustService ]); } - $doc->update([ - 'status' => 'posted', - 'posted_at' => now(), - 'posted_by' => $userId, - ]); + // 使用 saveQuietly 避免重複產生自動日誌 + $doc->status = 'posted'; + $doc->posted_at = now(); + $doc->posted_by = $userId; + $doc->saveQuietly(); + + // 準備品項快照供日誌使用 + $itemsSnapshot = $doc->items->map(function($item) { + return [ + 'product_name' => $item->product->name, + 'old' => null, // 過帳視為整單生效,不顯示個別欄位差異 + 'new' => [ + 'adjust_qty' => (float)$item->adjust_qty, + 'notes' => $item->notes, + ] + ]; + })->toArray(); + + // 手動產生過帳日誌 + activity() + ->performedOn($doc) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'attributes' => [ + 'status' => 'posted', + 'posted_at' => $doc->posted_at->format('Y-m-d H:i:s'), + 'posted_by' => $userId, + ], + 'old' => [ + 'status' => $oldStatus, + 'posted_at' => null, + 'posted_by' => null, + ], + 'items_diff' => [ + 'updated' => $itemsSnapshot, + ] + ]) + ->log('posted'); // 4. 若關聯盤點單,連動更新盤點單狀態 if ($doc->count_doc_id) { - InventoryCountDoc::where('id', $doc->count_doc_id)->update([ - 'status' => 'adjusted' - ]); + $countDoc = InventoryCountDoc::find($doc->count_doc_id); + if ($countDoc) { + $countDoc->status = 'adjusted'; + $countDoc->saveQuietly(); // 盤點單也靜默更新 + } } }); } @@ -152,9 +246,20 @@ class AdjustService if ($doc->status !== 'draft') { throw new \Exception('只能作廢草稿狀態的單據'); } - $doc->update([ - 'status' => 'voided', - 'updated_by' => $userId - ]); + + $oldStatus = $doc->status; + $doc->status = 'voided'; + $doc->updated_by = $userId; + $doc->saveQuietly(); + + activity() + ->performedOn($doc) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'attributes' => ['status' => 'voided'], + 'old' => ['status' => $oldStatus] + ]) + ->log('voided'); } } diff --git a/app/Modules/Inventory/Services/CountService.php b/app/Modules/Inventory/Services/CountService.php index a373690..f4d540b 100644 --- a/app/Modules/Inventory/Services/CountService.php +++ b/app/Modules/Inventory/Services/CountService.php @@ -145,16 +145,20 @@ class CountService $newDocAttributesLog = []; if ($isAllCounted) { - if ($doc->status !== 'completed') { - $doc->status = 'completed'; + // 檢查是否有任何差異 + $hasDiff = $doc->items()->where('diff_qty', '!=', 0)->exists(); + $targetStatus = $hasDiff ? 'completed' : 'no_adjust'; + + if ($doc->status !== $targetStatus) { + $doc->status = $targetStatus; $doc->completed_at = now(); $doc->completed_by = auth()->id(); $doc->saveQuietly(); - $doc->refresh(); // 獲取更新後的屬性 (如時間) + $doc->refresh(); $newDocAttributesLog = [ - 'status' => 'completed', + 'status' => $targetStatus, 'completed_at' => $doc->completed_at->format('Y-m-d H:i:s'), 'completed_by' => $doc->completed_by, ]; diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 6b83451..2e220ec 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -146,7 +146,9 @@ const fieldLabels: Record = { created_by: '建立者', updated_by: '更新者', completed_by: '完成者', + posted_by: '過帳者', counted_qty: '盤點數量', + adjust_qty: '調整數量', }; // 狀態翻譯對照表 @@ -164,6 +166,8 @@ const statusMap: Record = { // 庫存單據狀態 counting: '盤點中', posted: '已過帳', + no_adjust: '無需盤調', + adjusted: '已盤調', // 生產工單狀態 planned: '已計畫', in_progress: '生產中', @@ -492,7 +496,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P {/* 更新項目 */} - {activity.properties.items_diff.updated.map((item: any, idx: number) => ( + {activity.properties.items_diff.updated?.map((item: any, idx: number) => ( {item.product_name} @@ -500,41 +504,46 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
- {item.old.quantity !== item.new.quantity && ( + {item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
數量: {item.old.quantity}{item.new.quantity}
)} - {item.old.counted_qty !== item.new.counted_qty && ( + {item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
盤點量: {item.old.counted_qty ?? '未盤'}{item.new.counted_qty ?? '未盤'}
)} - {item.old.unit_name !== item.new.unit_name && ( + {item.old?.adjust_qty !== item.new?.adjust_qty && ( +
調整量: {item.old?.adjust_qty ?? '0'}{item.new?.adjust_qty ?? '0'}
+ )} + {item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && (
單位: {item.old.unit_name || '-'}{item.new.unit_name || '-'}
)} - {item.old.subtotal !== item.new.subtotal && ( + {item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && (
小計: ${item.old.subtotal}${item.new.subtotal}
)} - {item.old.notes !== item.new.notes && ( -
備註: {item.old.notes || '-'}{item.new.notes || '-'}
+ {item.old?.notes !== item.new?.notes && ( +
備註: {item.old?.notes || '-'}{item.new?.notes || '-'}
)}
- ))} + )) || null} {/* 新增項目 */} - {activity.properties.items_diff.added.map((item: any, idx: number) => ( + {activity.properties.items_diff.added?.map((item: any, idx: number) => ( {item.product_name} 新增 - 數量: {item.quantity} {item.unit_name} / 小計: ${item.subtotal} + {item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''} + {item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''} + {item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''} - ))} + )) || null} {/* 移除項目 */} - {activity.properties.items_diff.removed.map((item: any, idx: number) => ( + {activity.properties.items_diff.removed?.map((item: any, idx: number) => ( {item.product_name} @@ -544,7 +553,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P 原紀錄: {item.quantity} {item.unit_name} - ))} + )) || null}
diff --git a/resources/js/Pages/Inventory/Count/Index.tsx b/resources/js/Pages/Inventory/Count/Index.tsx index b3ec016..2dbd42d 100644 --- a/resources/js/Pages/Inventory/Count/Index.tsx +++ b/resources/js/Pages/Inventory/Count/Index.tsx @@ -143,6 +143,8 @@ export default function Index({ docs, warehouses, filters }: any) { return 盤點中; case 'completed': return 盤點完成; + case 'no_adjust': + return 盤點完成 (無需盤調); case 'adjusted': return 已盤調庫存; case 'cancelled': @@ -307,7 +309,7 @@ export default function Index({ docs, warehouses, filters }: any) {
{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */} {(() => { - const isEditable = !['completed', 'adjusted'].includes(doc.status); + const isEditable = !['completed', 'no_adjust', 'adjusted'].includes(doc.status); const canEdit = can('inventory_count.edit'); const canView = can('inventory_count.view'); @@ -343,7 +345,7 @@ export default function Index({ docs, warehouses, filters }: any) { return null; })()} - {!['completed', 'adjusted'].includes(doc.status) && ( + {!['completed', 'no_adjust', 'adjusted'].includes(doc.status) && ( - {doc.status === 'completed' && ( + {['completed', 'no_adjust'].includes(doc.status) && (