Files
star-erp/.agents/skills/activity-logging/SKILL.md

286 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<string, string> = {
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` 兩處同步?