Files

9.5 KiB
Raw Permalink 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 兩處同步?