docs(skill): 更新操作紀錄實作規範
整合全域 ID 轉名稱邏輯、日誌合併策略以及針對 Collection 修改錯誤的修復方案。
This commit is contained in:
@@ -1,158 +1,111 @@
|
|||||||
---
|
---
|
||||||
name: 操作紀錄實作規範
|
name: 操作紀錄實作規範
|
||||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 操作紀錄實作規範
|
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||||
|
|
||||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
本文件定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||||
|
|
||||||
## 1. 後端實作標準 (Backend)
|
---
|
||||||
|
|
||||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
## 1. 後端實作核心 (Backend)
|
||||||
|
|
||||||
### 1.1 啟用 Activity Log
|
### 1.1 全域 ID 轉名稱邏輯 (Global ID Resolution)
|
||||||
|
為了讓管理者能直覺看懂日誌,所有的 ID(如 `warehouse_id`, `created_by`)在記錄時都應自動解析為名稱。此邏輯應統一在 Model 的 `tapActivity` 中實作。
|
||||||
在 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
|
```php
|
||||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
{
|
{
|
||||||
$properties = $activity->properties;
|
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||||
|
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||||
|
? $activity->properties->toArray()
|
||||||
|
: $activity->properties;
|
||||||
|
|
||||||
|
// 1. Snapshot 快照:用於主描述的上下文(例如:單號、名稱)
|
||||||
$snapshot = $properties['snapshot'] ?? [];
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
$snapshot['doc_no'] = $this->doc_no;
|
||||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
$snapshot['warehouse_name'] = $this->warehouse?->name;
|
||||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
|
||||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
|
||||||
|
|
||||||
// 保存自身名稱 (Context)
|
|
||||||
$snapshot['name'] = $this->name;
|
|
||||||
|
|
||||||
$properties['snapshot'] = $snapshot;
|
$properties['snapshot'] = $snapshot;
|
||||||
|
|
||||||
|
// 2. 名稱解析:自動將 attributes 與 old 中的 ID 換成人名/物名
|
||||||
|
$resolver = function (&$data) {
|
||||||
|
if (empty($data) || !is_array($data)) return;
|
||||||
|
|
||||||
|
// 使用者 ID 轉換
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 倉庫 ID 轉換
|
||||||
|
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||||
|
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||||
|
if (isset($properties['old'])) $resolver($properties['old']);
|
||||||
|
|
||||||
$activity->properties = $properties;
|
$activity->properties = $properties;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. 顯示名稱映射 (UI Mapping)
|
### 1.2 複雜操作的日誌合併 (Log Consolidation)
|
||||||
|
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||||
|
|
||||||
### 2.1 對象名稱映射 (Mapping)
|
* **策略**:在 Service 層手動發送主日誌,並使用 `saveQuietly()` 更新單據屬性以抑止 Trait 的自動日誌。
|
||||||
|
* **格式**:主日誌應包含 `items_diff` (品項差異) 與 `attributes/old` (單據狀態變更)。
|
||||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
|
||||||
|
|
||||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
protected function getSubjectMap()
|
// Service 中的實作方式
|
||||||
{
|
DB::transaction(function () use ($doc, $items) {
|
||||||
return [
|
// 1. 更新品項 (記錄變動細節)
|
||||||
'App\Modules\Inventory\Models\Product' => '商品',
|
$updatedItems = $this->getUpdatedItems($doc, $items);
|
||||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
|
||||||
];
|
// 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
|
||||||
}
|
$doc->status = 'completed';
|
||||||
|
$doc->saveQuietly();
|
||||||
|
|
||||||
|
// 3. 手動觸發單一合併日誌
|
||||||
|
activity()
|
||||||
|
->performedOn($doc)
|
||||||
|
->withProperties([
|
||||||
|
'items_diff' => ['updated' => $updatedItems],
|
||||||
|
'attributes' => ['status' => 'completed'],
|
||||||
|
'old' => ['status' => 'counting']
|
||||||
|
])
|
||||||
|
->log('updated');
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 欄位名稱中文化 (Field Translation)
|
---
|
||||||
|
|
||||||
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
## 2. 前端介面規範 (Frontend)
|
||||||
|
|
||||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
### 2.1 標籤命名規範 (Field Labels)
|
||||||
|
前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。
|
||||||
|
|
||||||
|
**檔案位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||||
```typescript
|
```typescript
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
// ... 既有欄位
|
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||||
'transaction_date': '費用日期',
|
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||||
'category': '費用類別',
|
completed_by: '完成者',
|
||||||
'amount': '金額',
|
status: '狀態',
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 前端顯示邏輯 (Frontend)
|
### 2.2 特殊結構顯示
|
||||||
|
* **品項異動**:前端應能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」的方式呈現表格(已在 `ActivityDetailDialog` 實作)。
|
||||||
|
|
||||||
### 3.1 列表描述生成 (Description Generation)
|
---
|
||||||
|
|
||||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
## 3. 開發檢核清單 (Checklist)
|
||||||
|
|
||||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
- [ ] **Model**: `tapActivity` 是否已處理 Collection 快照?
|
||||||
|
- [ ] **Model**: 是否已實作全域 ID 至名稱的自動解析?
|
||||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
- [ ] **Service**: 是否使用 `saveQuietly()` 避免產生重複的「單據已更新」日誌?
|
||||||
|
- [ ] **UI**: `fieldLabels` 是否已移除所有「ID」字樣?
|
||||||
```typescript
|
- [ ] **UI**: 若有品項異動,是否已正確格式化傳入 `items_diff`?
|
||||||
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`?
|
|
||||||
|
|||||||
Reference in New Issue
Block a user