--- name: 操作紀錄實作規範 (Activity Logging Skill) description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。 --- # 操作紀錄實作規範 (Activity Logging Skill) 本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。 --- ## 1. 啟用 Activity Log (Model 基本設定) 在 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(); // 若無變動則不記錄 } } ``` --- ## 2. `tapActivity` 實作規範 (Backend 核心) ### 2.1 型別宣告:統一使用 `Contracts\Activity` ```php // ✅ 正確:使用介面 public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) // ❌ 禁止:使用具體類別 public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName) ``` ### 2.2 必須 `toArray()` 避免 Indirect modification error ```php // 🚩 核心:轉換為陣列以避免 Indirect modification error $properties = $activity->properties instanceof \Illuminate\Support\Collection ? $activity->properties->toArray() : $activity->properties; // ... 操作 $properties ... $activity->properties = $properties; // 最後整體回寫 ``` ### 2.3 Snapshot 快照策略 為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊。 ```php $snapshot = $properties['snapshot'] ?? []; $snapshot['doc_no'] = $this->doc_no; // 單號 $snapshot['name'] = $this->name; // 名稱 $snapshot['warehouse_name'] = $this->warehouse?->name; // 關聯名稱 $properties['snapshot'] = $snapshot; ``` ### 2.4 全域 ID 轉名稱邏輯 (ID Resolution) 所有的 ID(如 `warehouse_id`, `created_by`)在記錄時應自動解析為名稱。 #### 模組內 Model:可直接查詢 ```php $resolver = function (&$data) { if (empty($data) || !is_array($data)) return; // 同模組內的 Model 可以直接查詢 foreach (['created_by', 'updated_by', 'completed_by'] as $f) { if (isset($data[$f]) && is_numeric($data[$f])) { $data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name; } } }; ``` #### 跨模組 Model:必須透過 Service Interface > [!IMPORTANT] > 依據跨模組通訊規範,若需解析其他模組的 ID(例如在 `Procurement` 模組中解析 `warehouse_id`), > **禁止**直接 `Warehouse::find()`,必須透過 Service Interface。 ```php // ✅ 正確:透過 Service Interface 取得跨模組資料 if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) { $warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class) ->getWarehouse($data['warehouse_id']); $data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id']; } ``` > [!NOTE] > `Core` 模組的 `User`, `Role`, `Tenant` 屬於全域例外,其他模組可直接查詢。 > 詳見 [跨模組通訊規範](file:///home/mama/projects/star-erp/.agents/skills/cross-module-communication/SKILL.md)。 ### 2.5 完整 `tapActivity` 範例(參考 PurchaseOrder) ```php public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { // 🚩 轉換為陣列 $properties = $activity->properties instanceof \Illuminate\Support\Collection ? $activity->properties->toArray() : $activity->properties; // 1. Snapshot 快照 $snapshot = $properties['snapshot'] ?? []; $snapshot['po_number'] = $this->code; $snapshot['vendor_name'] = $this->vendor?->name; $properties['snapshot'] = $snapshot; // 2. ID 轉名稱 $resolver = function (&$data) { if (empty($data) || !is_array($data)) return; // 全域例外:User 可直接查 foreach (['user_id', 'created_by', 'updated_by'] as $f) { if (isset($data[$f]) && is_numeric($data[$f])) { $data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f]; } } // 同模組:可直接查 if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) { $data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id']; } // 跨模組:必須透過 Service Interface if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) { $warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class) ->getWarehouse($data['warehouse_id']); $data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id']; } }; if (isset($properties['attributes'])) $resolver($properties['attributes']); if (isset($properties['old'])) $resolver($properties['old']); // 3. 合併 activityProperties (手動傳入的 items_diff 等) if (!empty($this->activityProperties)) { $properties = array_merge($properties, $this->activityProperties); } $activity->properties = $properties; } ``` --- ## 3. 複雜操作的日誌合併 (Log Consolidation) 當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。 ### 3.1 手動記錄必須自行過濾差異 ```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'); } ``` ### 3.2 `saveQuietly()` + 手動日誌 合併策略 ```php DB::transaction(function () use ($doc, $items) { // 1. 更新品項 (記錄變動細節) $updatedItems = $this->getUpdatedItems($doc, $items); // 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌) $doc->status = 'completed'; $doc->saveQuietly(); // 3. 手動觸發單一合併日誌 activity() ->performedOn($doc) ->withProperties([ 'items_diff' => ['updated' => $updatedItems], 'attributes' => ['status' => 'completed'], 'old' => ['status' => 'counting'] ]) ->log('updated'); }); ``` > [!WARNING] > 使用 `saveQuietly()` 會繞過 Model Events(如自動單號產生)。 > 若 Model 有 `creating`/`updating` 事件產生單號,需在 Service 中手動處理。 --- ## 4. 後端 Controller 映射 (Subject Map) 新增 Model 時,必須同步在 `ActivityLogController::getSubjectMap()` 加入中文映射。 **位置**: `app/Modules/Core/Controllers/ActivityLogController.php` ```php private function getSubjectMap() { return [ 'App\Modules\Inventory\Models\Product' => '商品', 'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ... 新增此行 ]; } ``` --- ## 5. 前端介面規範 (Frontend) ### 5.1 標籤命名規範 (Field Labels) 前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。 **位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` ```typescript const fieldLabels: Record = { warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」 created_by: '建立者', // ❌ 禁用「建立者 ID」 completed_by: '完成者', status: '狀態', // 新增 Model 的欄位翻譯 ... }; ``` ### 5.2 `nameParams` 必須在兩處同步更新 > [!IMPORTANT] > `nameParams` 在 `LogTable.tsx` 和 `ActivityDetailDialog.tsx` 中各有一份, > 新增時**必須兩處同步更新**,否則會導致列表與詳情頁顯示不一致。 | 檔案 | 用途 | |---|---| | `resources/js/Components/ActivityLog/LogTable.tsx` | 列表頁的描述文字 | | `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` | 對話框標題 | ### 5.3 特殊結構顯示 * **品項異動**:前端已能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」方式呈現表格。 * **顯示過濾邏輯**(已內建於 `ActivityDetailDialog`): - **Created**: 顯示初始化欄位 - **Updated**: 僅顯示有變動的欄位 (`isChanged` 判斷) - **Deleted**: 顯示刪除前的完整資料 --- ## 6. 開發檢核清單 (Checklist) - [ ] **Model**: 是否已設定 `logOnlyDirty` + `dontSubmitEmptyLogs`? - [ ] **Model**: `tapActivity` 型別是否使用 `Contracts\Activity`? - [ ] **Model**: `tapActivity` 是否已使用 `toArray()` 處理 Collection? - [ ] **Model**: 是否已實作 Snapshot(關鍵識別資訊)? - [ ] **Model**: ID 轉名稱是否遵守跨模組規範(Core 例外,其餘需透過 Interface)? - [ ] **Service**: 是否使用 `saveQuietly()` 搭配手動 `activity()` 避免重複日誌? - [ ] **Controller**: `ActivityLogController::getSubjectMap()` 是否已新增 Model 中文映射? - [ ] **UI**: `fieldLabels` 是否已新增欄位中文翻譯? - [ ] **UI**: `nameParams` 是否已在 `LogTable` 和 `ActivityDetailDialog` 兩處同步?