diff --git a/app/Modules/Core/Controllers/ActivityLogController.php b/app/Modules/Core/Controllers/ActivityLogController.php index ac5ec6f..ae8dc47 100644 --- a/app/Modules/Core/Controllers/ActivityLogController.php +++ b/app/Modules/Core/Controllers/ActivityLogController.php @@ -28,6 +28,7 @@ class ActivityLogController extends Controller 'App\Modules\Production\Models\Recipe' => '生產配方', 'App\Modules\Production\Models\RecipeItem' => '配方品項', 'App\Modules\Production\Models\ProductionOrderItem' => '工單品項', + 'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單', ]; } diff --git a/app/Modules/Inventory/Controllers/CountDocController.php b/app/Modules/Inventory/Controllers/CountDocController.php index a1c2842..8dfbd7e 100644 --- a/app/Modules/Inventory/Controllers/CountDocController.php +++ b/app/Modules/Inventory/Controllers/CountDocController.php @@ -84,7 +84,7 @@ class CountDocController extends Controller ); // 自動執行快照 - $this->countService->snapshot($doc); + $this->countService->snapshot($doc, false); return redirect()->route('inventory.count.show', [$doc->id]) ->with('success', '已建立盤點單並完成庫存快照'); @@ -173,14 +173,37 @@ class CountDocController extends Controller $this->countService->updateCount($doc, $validated['items']); } - // 如果是按了 "完成盤點" - if ($request->input('action') === 'complete') { - $this->countService->complete($doc, auth()->id()); + // 重新讀取以獲取最新狀態 + $doc->refresh(); + + if ($doc->status === 'completed') { return redirect()->route('inventory.count.index') - ->with('success', '盤點單已完成'); + ->with('success', '盤點完成,單據已自動存檔並完成。'); } - return redirect()->back()->with('success', '暫存成功'); + return redirect()->back()->with('success', '盤點資料已暫存'); + } + + public function reopen(InventoryCountDoc $doc) + { + // 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust) + // 注意:前端已經用 保護按鈕,後端這裡最好也加上檢查 + if (!auth()->user()->can('inventory.adjust')) { + abort(403); + } + + if ($doc->status !== 'completed') { + return redirect()->back()->with('error', '僅能針對已完成的盤點單重新開啟盤點'); + } + + // 執行取消核准邏輯 + $doc->update([ + 'status' => 'counting', // 回復為盤點中 + 'completed_at' => null, // 清除完成時間 + 'completed_by' => null, // 清除完成者 + ]); + + return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態'); } public function destroy(InventoryCountDoc $doc) @@ -189,18 +212,7 @@ class CountDocController 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'); + // Activity Log handled by Model Trait $doc->items()->delete(); $doc->delete(); diff --git a/app/Modules/Inventory/Models/InventoryCountDoc.php b/app/Modules/Inventory/Models/InventoryCountDoc.php index c41973f..92386ea 100644 --- a/app/Modules/Inventory/Models/InventoryCountDoc.php +++ b/app/Modules/Inventory/Models/InventoryCountDoc.php @@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Model; 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 InventoryCountDoc extends Model { use HasFactory; + use LogsActivity; protected $fillable = [ 'doc_no', @@ -75,4 +78,65 @@ class InventoryCountDoc extends Model { return $this->belongsTo(User::class, 'completed_by'); } + + public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions + { + return \Spatie\Activitylog\LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->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['completed_at'] = $this->completed_at ? $this->completed_at->format('Y-m-d H:i:s') : null; + $snapshot['status'] = $this->status; + $snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null; + $snapshot['completed_by_name'] = $this->completedBy ? $this->completedBy->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', 'completed_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/Services/CountService.php b/app/Modules/Inventory/Services/CountService.php index 3404e5e..a373690 100644 --- a/app/Modules/Inventory/Services/CountService.php +++ b/app/Modules/Inventory/Services/CountService.php @@ -20,7 +20,8 @@ class CountService return DB::transaction(function () use ($warehouseId, $remarks, $userId) { $doc = InventoryCountDoc::create([ 'warehouse_id' => $warehouseId, - 'status' => 'draft', + 'status' => 'counting', + 'snapshot_date' => now(), 'remarks' => $remarks, 'created_by' => $userId, ]); @@ -32,9 +33,9 @@ class CountService /** * 執行快照:鎖定當前庫存量 */ - public function snapshot(InventoryCountDoc $doc): void + public function snapshot(InventoryCountDoc $doc, bool $updateDoc = true): void { - DB::transaction(function () use ($doc) { + DB::transaction(function () use ($doc, $updateDoc) { // 清除舊的 items (如果有) $doc->items()->delete(); @@ -62,10 +63,12 @@ class CountService InventoryCountItem::insert($items); } - $doc->update([ - 'status' => 'counting', - 'snapshot_date' => now(), - ]); + if ($updateDoc) { + $doc->update([ + 'status' => 'counting', + 'snapshot_date' => now(), + ]); + } }); } @@ -91,19 +94,111 @@ class CountService public function updateCount(InventoryCountDoc $doc, array $itemsData): void { DB::transaction(function () use ($doc, $itemsData) { + $updatedItems = []; + $hasChanges = false; + $oldDocAttributes = [ + 'status' => $doc->status, + 'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i:s') : null, + 'completed_by' => $doc->completed_by, + ]; + foreach ($itemsData as $data) { - $item = $doc->items()->find($data['id']); + $item = $doc->items()->with('product')->find($data['id']); if ($item) { - $countedQty = $data['counted_qty']; - $diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0; + $oldQty = $item->counted_qty; + $newQty = $data['counted_qty']; + $oldNotes = $item->notes; + $newNotes = $data['notes'] ?? $item->notes; - $item->update([ - 'counted_qty' => $countedQty, - 'diff_qty' => $diff, - 'notes' => $data['notes'] ?? $item->notes, - ]); + $isQtyChanged = $oldQty != $newQty; + $isNotesChanged = $oldNotes !== $newNotes; + + if ($isQtyChanged || $isNotesChanged) { + $updatedItems[] = [ + 'product_name' => $item->product->name, + 'old' => [ + 'counted_qty' => $oldQty, + 'notes' => $oldNotes, + ], + 'new' => [ + 'counted_qty' => $newQty, + 'notes' => $newNotes, + ] + ]; + + $countedQty = $data['counted_qty']; + $diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0; + + $item->update([ + 'counted_qty' => $countedQty, + 'diff_qty' => $diff, + 'notes' => $newNotes, + ]); + $hasChanges = true; + } } } + + // 檢查是否完成 + $doc->refresh(); + $isAllCounted = $doc->items()->whereNull('counted_qty')->count() === 0; + $newDocAttributesLog = []; + + if ($isAllCounted) { + if ($doc->status !== 'completed') { + $doc->status = 'completed'; + $doc->completed_at = now(); + $doc->completed_by = auth()->id(); + $doc->saveQuietly(); + + $doc->refresh(); // 獲取更新後的屬性 (如時間) + + $newDocAttributesLog = [ + 'status' => 'completed', + 'completed_at' => $doc->completed_at->format('Y-m-d H:i:s'), + 'completed_by' => $doc->completed_by, + ]; + $hasChanges = true; + } + } else { + if ($doc->status === 'completed') { + $doc->status = 'counting'; + $doc->completed_at = null; + $doc->completed_by = null; + $doc->saveQuietly(); + + $newDocAttributesLog = [ + 'status' => 'counting', + 'completed_at' => null, + 'completed_by' => null, + ]; + $hasChanges = true; + } + } + + // 記錄操作日誌 + if ($hasChanges) { + $properties = [ + 'items_diff' => [ + 'added' => [], + 'removed' => [], + 'updated' => $updatedItems, + ], + ]; + + // 如果有文件層級的屬性變更 (狀態),併入 log + if (!empty($newDocAttributesLog)) { + $properties['attributes'] = $newDocAttributesLog; + $properties['old'] = array_intersect_key($oldDocAttributes, $newDocAttributesLog); + } + + activity() + ->performedOn($doc) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties($properties) + ->log('updated'); + } }); } } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 37a8f22..6b83451 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -143,6 +143,10 @@ const fieldLabels: Record = { reason: '原因', count_doc_id: '盤點單 ID', count_doc_no: '盤點單號', + created_by: '建立者', + updated_by: '更新者', + completed_by: '完成者', + counted_qty: '盤點數量', }; // 狀態翻譯對照表 @@ -271,6 +275,25 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return value.split('T')[0].split(' ')[0]; } + // 處理日期時間欄位 (YYYY-MM-DD HH:mm:ss) + if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at') && typeof value === 'string') { + try { + const date = new Date(value); + return date.toLocaleString('zh-TW', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(/\//g, '-'); + } catch (e) { + return value; + } + } + return String(value); }; @@ -301,7 +324,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return `${wName} - ${pName}`; } - const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; + const nameParams = ['doc_no', 'po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; for (const param of nameParams) { if (snapshot[param]) return snapshot[param]; if (attributes[param]) return attributes[param]; @@ -480,12 +503,18 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P {item.old.quantity !== item.new.quantity && (
數量: {item.old.quantity}{item.new.quantity}
)} + {item.old.counted_qty !== item.new.counted_qty && ( +
盤點量: {item.old.counted_qty ?? '未盤'}{item.new.counted_qty ?? '未盤'}
+ )} {item.old.unit_name !== item.new.unit_name && (
單位: {item.old.unit_name || '-'}{item.new.unit_name || '-'}
)} {item.old.subtotal !== item.new.subtotal && (
小計: ${item.old.subtotal}${item.new.subtotal}
)} + {item.old.notes !== item.new.notes && ( +
備註: {item.old.notes || '-'}{item.new.notes || '-'}
+ )} diff --git a/resources/js/Components/ActivityLog/ActivityLog.tsx b/resources/js/Components/ActivityLog/ActivityLog.tsx new file mode 100644 index 0000000..cca5c0e --- /dev/null +++ b/resources/js/Components/ActivityLog/ActivityLog.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import LogTable, { Activity } from './LogTable'; +import ActivityDetailDialog from './ActivityDetailDialog'; +import { History } from 'lucide-react'; + +interface Props { + activities: Activity[]; + className?: string; +} + +export default function ActivityLog({ activities, className = '' }: Props) { + const [selectedActivity, setSelectedActivity] = useState(null); + const [isDetailOpen, setIsDetailOpen] = useState(false); + + const handleViewDetail = (activity: Activity) => { + setSelectedActivity(activity); + setIsDetailOpen(true); + }; + + return ( +
+
+ +

操作紀錄

+
+ + + + +
+ ); +} diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx index 51fd866..1bfc191 100644 --- a/resources/js/Components/ActivityLog/LogTable.tsx +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -63,7 +63,7 @@ export default function LogTable({ // 嘗試在快照、屬性或舊值中尋找名稱 // 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID - const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category']; + const nameParams = ['doc_no', 'po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category']; let subjectName = ''; // 庫存的特殊處理:顯示 "倉庫 - 商品" diff --git a/resources/js/Pages/Inventory/Count/Index.tsx b/resources/js/Pages/Inventory/Count/Index.tsx index 9e953c1..b3ec016 100644 --- a/resources/js/Pages/Inventory/Count/Index.tsx +++ b/resources/js/Pages/Inventory/Count/Index.tsx @@ -1,5 +1,5 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, Link, useForm, router, usePage } from '@inertiajs/react'; +import { Head, Link, useForm, router } from '@inertiajs/react'; import { useState, useCallback, useEffect } from 'react'; import { usePermission } from '@/hooks/usePermission'; import { debounce } from "lodash"; @@ -47,7 +47,7 @@ import { import Pagination from '@/Components/shared/Pagination'; import { Can } from '@/Components/Permission/Can'; -export default function Index({ auth, docs, warehouses, filters }: any) { +export default function Index({ docs, warehouses, filters }: any) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const { data, setData, post, processing, reset, errors, delete: destroy } = useForm({ @@ -112,7 +112,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) { ); }; - const handleCreate = (e) => { + const handleCreate = (e: React.FormEvent) => { e.preventDefault(); post(route('inventory.count.store'), { onSuccess: () => { @@ -135,14 +135,14 @@ export default function Index({ auth, docs, warehouses, filters }: any) { } }; - const getStatusBadge = (status) => { + const getStatusBadge = (status: string) => { switch (status) { case 'draft': return 草稿; case 'counting': return 盤點中; case 'completed': - return 已核准; + return 盤點完成; case 'adjusted': return 已盤調庫存; case 'cancelled': @@ -287,7 +287,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) { ) : ( - docs.data.map((doc, index) => ( + docs.data.map((doc: any, index: number) => ( {(docs.current_page - 1) * docs.per_page + index + 1} diff --git a/resources/js/Pages/Inventory/Count/Show.tsx b/resources/js/Pages/Inventory/Count/Show.tsx index e7c3aaf..6bbb311 100644 --- a/resources/js/Pages/Inventory/Count/Show.tsx +++ b/resources/js/Pages/Inventory/Count/Show.tsx @@ -12,7 +12,7 @@ import { import { Button } from '@/Components/ui/button'; import { Input } from '@/Components/ui/input'; import { Badge } from '@/Components/ui/badge'; -import { Save, CheckCircle, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft +import { Save, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft import { AlertDialog, AlertDialogAction, @@ -26,6 +26,7 @@ import { } from "@/Components/ui/alert-dialog" import { Can } from '@/Components/Permission/Can'; + export default function Show({ doc }: any) { // Transform items to form data structure const { data, setData, put, delete: destroy, processing, transform } = useForm({ @@ -102,7 +103,7 @@ export default function Show({ doc }: any) { 盤點單: {doc.doc_no} {doc.status === 'completed' && ( - 已核准 + 盤點完成 )} {doc.status === 'adjusted' && ( 已盤調庫存 @@ -138,19 +139,19 @@ export default function Show({ doc }: any) { disabled={processing} > - 取消核准 + 重新開啟盤點 - 確定要取消核准嗎? + 確定要重新開啟盤點嗎? - 單據將回復為「盤點中」狀態,若已產生庫存異動將被撤回。此動作可讓您重新編輯盤點數量。 + 單據將回復為「盤點中」狀態。此動作可讓您重新編輯盤點數量。 取消 - 確認取消核准 + 確認重新開啟 @@ -184,23 +185,13 @@ export default function Show({ doc }: any) { - @@ -318,6 +309,7 @@ export default function Show({ doc }: any) { +