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

9.5 KiB
Raw Blame History

name, description
name description
操作紀錄實作規範 (Activity Logging Skill) 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。

操作紀錄實作規範 (Activity Logging Skill)

本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。


1. 啟用 Activity Log (Model 基本設定)

在 Model 中引用 LogsActivity trait 並實作 getActivitylogOptions 方法。

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

// ✅ 正確:使用介面
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

// 🚩 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection 
    ? $activity->properties->toArray() 
    : $activity->properties;

// ... 操作 $properties ...

$activity->properties = $properties; // 最後整體回寫

2.3 Snapshot 快照策略

為確保資料被刪除後仍能辨識操作對象,必須properties.snapshot 中儲存關鍵識別資訊。

$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)

所有的 IDwarehouse_id, created_by)在記錄時應自動解析為名稱。

模組內 Model可直接查詢

$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。

// ✅ 正確:透過 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 屬於全域例外,其他模組可直接查詢。 詳見 跨模組通訊規範

2.5 完整 tapActivity 範例(參考 PurchaseOrder

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 手動記錄必須自行過濾差異

// ✅ 正確:自行比對差異,只存變動值
$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() + 手動日誌 合併策略

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

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

const fieldLabels: Record<string, string> = {
    warehouse_id: '倉庫',     // ❌ 禁用「倉庫 ID」
    created_by: '建立者',     // ❌ 禁用「建立者 ID」
    completed_by: '完成者',
    status: '狀態',
    // 新增 Model 的欄位翻譯 ...
};

5.2 nameParams 必須在兩處同步更新

Important

nameParamsLogTable.tsxActivityDetailDialog.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 是否已在 LogTableActivityDetailDialog 兩處同步?