diff --git a/.agent/skills/activity-logging/SKILL.md b/.agent/skills/activity-logging/SKILL.md new file mode 100644 index 0000000..f8ec3ec --- /dev/null +++ b/.agent/skills/activity-logging/SKILL.md @@ -0,0 +1,158 @@ +--- +name: 操作紀錄實作規範 +description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。 +--- + +# 操作紀錄實作規範 + +本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。 + +## 1. 後端實作標準 (Backend) + +所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。 + +### 1.1 啟用 Activity Log + +在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。 + +```php +use Spatie\Activitylog\Traits\LogsActivity; +use Spatie\Activitylog\LogOptions; + +class Product extends Model +{ + use LogsActivity; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位 + ->dontSubmitEmptyLogs(); // 若無變動則不記錄 + } +} +``` + +### 1.2 手動記錄 (Manual Logging) + +若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。 + +**錯誤範例 (Do NOT do this):** +```php +// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變 +activity() + ->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes]) + ->log('updated'); +``` + +**正確範例 (Do this):** +```php +// ✅ 正確:自行比對差異,只存變動值 +$changedAttributes = []; +$changedOldAttributes = []; + +foreach ($newAttributes as $key => $value) { + if ($value != ($oldAttributes[$key] ?? null)) { + $changedAttributes[$key] = $value; + $changedOldAttributes[$key] = $oldAttributes[$key] ?? null; + } +} + +if (!empty($changedAttributes)) { + activity() + ->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes]) + ->log('updated'); +} +``` + +### 1.3 快照策略 (Snapshot Strategy) + +為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。 + +**主要方式:使用 `tapActivity` (推薦)** + +```php +public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) +{ + $properties = $activity->properties; + $snapshot = $properties['snapshot'] ?? []; + + // 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效) + $snapshot['category_name'] = $this->category ? $this->category->name : null; + $snapshot['po_number'] = $this->code; // 儲存單號 + + // 保存自身名稱 (Context) + $snapshot['name'] = $this->name; + + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; +} +``` + +## 2. 顯示名稱映射 (UI Mapping) + +### 2.1 對象名稱映射 (Mapping) + +需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。 + +**位置**: `app/Http/Controllers/Admin/ActivityLogController.php` + +```php +protected function getSubjectMap() +{ + return [ + 'App\Models\Product' => '商品', + 'App\Models\UtilityFee' => '公共事業費', // ✅ 新增映射 + ]; +} +``` + +### 2.2 欄位名稱中文化 (Field Translation) + +需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。 + +**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` + +```typescript +const fieldLabels: Record = { + // ... 既有欄位 + 'transaction_date': '費用日期', + 'category': '費用類別', + 'amount': '金額', +}; +``` + +## 3. 前端顯示邏輯 (Frontend) + +### 3.1 列表描述生成 (Description Generation) + +前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。 + +若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。 + +**位置**: `resources/js/Components/ActivityLog/LogTable.tsx` + +```typescript +const nameParams = [ + 'po_number', 'name', 'code', + 'category_name', + 'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category'] +]; +``` + +### 3.2 詳情過濾邏輯 + +前端 `ActivityDetailDialog` 已內建智慧過濾邏輯: +- **Created**: 顯示初始化欄位。 +- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。 +- **Deleted**: 顯示刪除前的完整資料。 + +開發者僅需確保傳入的 `attributes` 與 `old` 資料結構正確,過濾邏輯會自動運作。 + +## 檢核清單 + +- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾? +- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot(關鍵名稱)? +- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射? +- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯? +- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable` 的 `nameParams`? diff --git a/app/Http/Controllers/Admin/ActivityLogController.php b/app/Http/Controllers/Admin/ActivityLogController.php index 8338771..0e351e0 100644 --- a/app/Http/Controllers/Admin/ActivityLogController.php +++ b/app/Http/Controllers/Admin/ActivityLogController.php @@ -21,6 +21,7 @@ class ActivityLogController extends Controller 'App\Models\PurchaseOrder' => '採購單', 'App\Models\Warehouse' => '倉庫', 'App\Models\Inventory' => '庫存', + 'App\Models\UtilityFee' => '公共事業費', ]; } diff --git a/app/Http/Controllers/UtilityFeeController.php b/app/Http/Controllers/UtilityFeeController.php index 663b137..cf43673 100644 --- a/app/Http/Controllers/UtilityFeeController.php +++ b/app/Http/Controllers/UtilityFeeController.php @@ -66,7 +66,22 @@ class UtilityFeeController extends Controller 'description' => 'nullable|string', ]); - UtilityFee::create($validated); + $fee = UtilityFee::create($validated); + + // Log activity + activity() + ->performedOn($fee) + ->causedBy(auth()->user()) + ->event('created') + ->withProperties([ + 'attributes' => $fee->getAttributes(), + 'snapshot' => [ + 'category' => $fee->category, + 'amount' => $fee->amount, + 'transaction_date' => $fee->transaction_date->format('Y-m-d'), + ] + ]) + ->log('created'); return redirect()->back(); } @@ -81,14 +96,81 @@ class UtilityFeeController extends Controller 'description' => 'nullable|string', ]); + // Capture old attributes before update + $oldAttributes = $utility_fee->getAttributes(); + $utility_fee->update($validated); + // Capture new attributes + $newAttributes = $utility_fee->getAttributes(); + + // Manual logOnlyDirty: Filter attributes to only include changes + $changedAttributes = []; + $changedOldAttributes = []; + + foreach ($newAttributes as $key => $value) { + // Skip timestamps if they are the only change (optional, but good practice) + if (in_array($key, ['updated_at'])) continue; + + $oldValue = $oldAttributes[$key] ?? null; + + // Simple comparison (casting to string to handle date objects vs strings if necessary, + // but Eloquent attributes are usually consistent if casted. + // Using loose comparison != handles most cases correctly) + if ($value != $oldValue) { + $changedAttributes[$key] = $value; + $changedOldAttributes[$key] = $oldValue; + } + } + + // Only log if there are changes (excluding just updated_at) + if (empty($changedAttributes)) { + return redirect()->back(); + } + + // Log activity with before/after comparison + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'attributes' => $changedAttributes, + 'old' => $changedOldAttributes, + 'snapshot' => [ + 'category' => $utility_fee->category, + 'amount' => $utility_fee->amount, + 'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'), + ] + ]) + ->log('updated'); + return redirect()->back(); } public function destroy(UtilityFee $utility_fee) { + // Capture data snapshot before deletion + $snapshot = [ + 'category' => $utility_fee->category, + 'amount' => $utility_fee->amount, + 'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'), + 'invoice_number' => $utility_fee->invoice_number, + 'description' => $utility_fee->description, + ]; + + // Log activity before deletion + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('deleted') + ->withProperties([ + 'attributes' => $utility_fee->getAttributes(), + 'snapshot' => $snapshot + ]) + ->log('deleted'); + $utility_fee->delete(); + return redirect()->back(); } } diff --git a/app/Models/UtilityFee.php b/app/Models/UtilityFee.php index 9917f13..aadaefc 100644 --- a/app/Models/UtilityFee.php +++ b/app/Models/UtilityFee.php @@ -4,12 +4,10 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Spatie\Activitylog\Traits\LogsActivity; -use Spatie\Activitylog\LogOptions; class UtilityFee extends Model { - use HasFactory, LogsActivity; + use HasFactory; protected $fillable = [ 'transaction_date', @@ -23,12 +21,4 @@ class UtilityFee extends Model 'transaction_date' => 'date:Y-m-d', 'amount' => 'decimal:2', ]; - - public function getActivitylogOptions(): LogOptions - { - return LogOptions::defaults() - ->logAll() - ->logOnlyDirty() - ->dontSubmitEmptyLogs(); - } } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 44035ed..9d88c89 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -101,6 +101,10 @@ const fieldLabels: Record = { invoice_date: '發票日期', invoice_amount: '發票金額', last_price: '供貨價格', + // Utility Fee fields + transaction_date: '費用日期', + category: '費用類別', + amount: '金額', }; // Purchase Order Status Map @@ -325,7 +329,18 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P {filteredKeys.some(key => !isSnapshotField(key)) ? ( filteredKeys - .filter(key => !isSnapshotField(key)) + .filter(key => { + if (isSnapshotField(key)) return false; + + // 如果是更新事件,僅顯示有變動的欄位 + if (activity.event === 'updated') { + const oldValue = old[key]; + const newValue = attributes[key]; + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + return true; + }) .map((key) => { const oldValue = old[key]; const newValue = attributes[key]; diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx index ed6bcb9..8e30c2a 100644 --- a/resources/js/Components/ActivityLog/LogTable.tsx +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -63,7 +63,7 @@ export default function LogTable({ // Try to find a name in snapshot, attributes or old values // Priority: snapshot > specific name fields > generic name > code > ID - const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; + const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category']; let subjectName = ''; // Special handling for Inventory: show "Warehouse - Product" diff --git a/resources/js/Pages/Admin/Role/Create.tsx b/resources/js/Pages/Admin/Role/Create.tsx index f601397..76088d5 100644 --- a/resources/js/Pages/Admin/Role/Create.tsx +++ b/resources/js/Pages/Admin/Role/Create.tsx @@ -74,6 +74,7 @@ export default function RoleCreate({ groupedPermissions }: Props) { 'adjust': '新增 / 調整', 'transfer': '調撥', 'safety_stock': '安全庫存設定', + 'export': '匯出', }; return map[action] || action; diff --git a/resources/js/Pages/Admin/Role/Edit.tsx b/resources/js/Pages/Admin/Role/Edit.tsx index b84783a..3f10b72 100644 --- a/resources/js/Pages/Admin/Role/Edit.tsx +++ b/resources/js/Pages/Admin/Role/Edit.tsx @@ -81,6 +81,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions 'adjust': '新增 / 調整', 'transfer': '調撥', 'safety_stock': '安全庫存設定', + 'export': '匯出', }; return map[action] || action;