優化公共事業費操作紀錄與新增操作紀錄規範 Skill
This commit is contained in:
158
.agent/skills/activity-logging/SKILL.md
Normal file
158
.agent/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範
|
||||
|
||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||
|
||||
## 1. 後端實作標準 (Backend)
|
||||
|
||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||
|
||||
### 1.1 啟用 Activity Log
|
||||
|
||||
在 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
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||
|
||||
// 保存自身名稱 (Context)
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 顯示名稱映射 (UI Mapping)
|
||||
|
||||
### 2.1 對象名稱映射 (Mapping)
|
||||
|
||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||
|
||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||
|
||||
```php
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Models\Product' => '商品',
|
||||
'App\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 欄位名稱中文化 (Field Translation)
|
||||
|
||||
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
// ... 既有欄位
|
||||
'transaction_date': '費用日期',
|
||||
'category': '費用類別',
|
||||
'amount': '金額',
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 前端顯示邏輯 (Frontend)
|
||||
|
||||
### 3.1 列表描述生成 (Description Generation)
|
||||
|
||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||
|
||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||
|
||||
```typescript
|
||||
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`?
|
||||
@@ -21,6 +21,7 @@ class ActivityLogController extends Controller
|
||||
'App\Models\PurchaseOrder' => '採購單',
|
||||
'App\Models\Warehouse' => '倉庫',
|
||||
'App\Models\Inventory' => '庫存',
|
||||
'App\Models\UtilityFee' => '公共事業費',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,22 @@ class UtilityFeeController extends Controller
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
UtilityFee::create($validated);
|
||||
$fee = UtilityFee::create($validated);
|
||||
|
||||
// Log activity
|
||||
activity()
|
||||
->performedOn($fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'attributes' => $fee->getAttributes(),
|
||||
'snapshot' => [
|
||||
'category' => $fee->category,
|
||||
'amount' => $fee->amount,
|
||||
'transaction_date' => $fee->transaction_date->format('Y-m-d'),
|
||||
]
|
||||
])
|
||||
->log('created');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
@@ -81,14 +96,81 @@ class UtilityFeeController extends Controller
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Capture old attributes before update
|
||||
$oldAttributes = $utility_fee->getAttributes();
|
||||
|
||||
$utility_fee->update($validated);
|
||||
|
||||
// Capture new attributes
|
||||
$newAttributes = $utility_fee->getAttributes();
|
||||
|
||||
// Manual logOnlyDirty: Filter attributes to only include changes
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
// Skip timestamps if they are the only change (optional, but good practice)
|
||||
if (in_array($key, ['updated_at'])) continue;
|
||||
|
||||
$oldValue = $oldAttributes[$key] ?? null;
|
||||
|
||||
// Simple comparison (casting to string to handle date objects vs strings if necessary,
|
||||
// but Eloquent attributes are usually consistent if casted.
|
||||
// Using loose comparison != handles most cases correctly)
|
||||
if ($value != $oldValue) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Only log if there are changes (excluding just updated_at)
|
||||
if (empty($changedAttributes)) {
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Log activity with before/after comparison
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => $changedAttributes,
|
||||
'old' => $changedOldAttributes,
|
||||
'snapshot' => [
|
||||
'category' => $utility_fee->category,
|
||||
'amount' => $utility_fee->amount,
|
||||
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function destroy(UtilityFee $utility_fee)
|
||||
{
|
||||
// Capture data snapshot before deletion
|
||||
$snapshot = [
|
||||
'category' => $utility_fee->category,
|
||||
'amount' => $utility_fee->amount,
|
||||
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
|
||||
'invoice_number' => $utility_fee->invoice_number,
|
||||
'description' => $utility_fee->description,
|
||||
];
|
||||
|
||||
// Log activity before deletion
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('deleted')
|
||||
->withProperties([
|
||||
'attributes' => $utility_fee->getAttributes(),
|
||||
'snapshot' => $snapshot
|
||||
])
|
||||
->log('deleted');
|
||||
|
||||
$utility_fee->delete();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class UtilityFee extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_date',
|
||||
@@ -23,12 +21,4 @@ class UtilityFee extends Model
|
||||
'transaction_date' => 'date:Y-m-d',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ const fieldLabels: Record<string, string> = {
|
||||
invoice_date: '發票日期',
|
||||
invoice_amount: '發票金額',
|
||||
last_price: '供貨價格',
|
||||
// Utility Fee fields
|
||||
transaction_date: '費用日期',
|
||||
category: '費用類別',
|
||||
amount: '金額',
|
||||
};
|
||||
|
||||
// Purchase Order Status Map
|
||||
@@ -325,7 +329,18 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
<TableBody>
|
||||
{filteredKeys.some(key => !isSnapshotField(key)) ? (
|
||||
filteredKeys
|
||||
.filter(key => !isSnapshotField(key))
|
||||
.filter(key => {
|
||||
if (isSnapshotField(key)) return false;
|
||||
|
||||
// 如果是更新事件,僅顯示有變動的欄位
|
||||
if (activity.event === 'updated') {
|
||||
const oldValue = old[key];
|
||||
const newValue = attributes[key];
|
||||
return JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((key) => {
|
||||
const oldValue = old[key];
|
||||
const newValue = attributes[key];
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function LogTable({
|
||||
|
||||
// Try to find a name in snapshot, attributes or old values
|
||||
// Priority: snapshot > specific name fields > generic name > code > ID
|
||||
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title'];
|
||||
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
|
||||
let subjectName = '';
|
||||
|
||||
// Special handling for Inventory: show "Warehouse - Product"
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
'adjust': '新增 / 調整',
|
||||
'transfer': '調撥',
|
||||
'safety_stock': '安全庫存設定',
|
||||
'export': '匯出',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
||||
'adjust': '新增 / 調整',
|
||||
'transfer': '調撥',
|
||||
'safety_stock': '安全庫存設定',
|
||||
'export': '匯出',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
Reference in New Issue
Block a user