Compare commits

..

4 Commits

Author SHA1 Message Date
db49f417df 新增 stancl/jobpipeline 依賴
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 43s
2026-01-21 13:14:26 +08:00
9e574fea85 更新 CI/CD 設定:正式機路徑改為 star-erp
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m47s
2026-01-21 13:06:01 +08:00
7eed761861 優化公共事業費操作紀錄與新增操作紀錄規範 Skill
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-21 11:46:16 +08:00
b3299618ce 完善公共事業費用與會計報表權限設定
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 新增 utility_fees 與 accounting 相關權限至 PermissionSeeder
- 更新 RoleController 加入權限群組中文標題映射
- 為會計報表匯出功能加上權限保護
- 前端加入 Can 組件保護按鈕顯示
- 更新權限管理 Skill 文件,補充 UI 顯示設定步驟
2026-01-21 10:55:11 +08:00
16 changed files with 323 additions and 31 deletions

View 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`

View File

@@ -110,10 +110,31 @@ export default function ProductIndex() {
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。 - `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。 - `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
## 5. 配置權限群組名稱 (Backend UI Config)
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
### 步驟:
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`
2. 找到 `getGroupedPermissions` 方法。
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
### 範例:
```php
$groupDefinitions = [
'products' => '商品資料管理',
// ...
'utility_fees' => '公共事業費管理', // 新增此行
];
```
## 檢核清單 ## 檢核清單
- [ ] `PermissionSeeder.php` 已新增權限字串。 - [ ] `PermissionSeeder.php` 已新增權限字串。
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。 - [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。 - [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。 - [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。 - [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。

View File

@@ -132,7 +132,7 @@ jobs:
--exclude='vendor' \ --exclude='vendor' \
--exclude='public/build' \ --exclude='public/build' \
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \ -e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
./ root@erp.koori.tw:/var/www/koori-erp-prod/ ./ root@erp.koori.tw:/var/www/star-erp/
rm ~/.ssh/id_rsa_prod rm ~/.ssh/id_rsa_prod
@@ -146,7 +146,7 @@ jobs:
username: root username: root
key: ${{ secrets.PROD_SSH_KEY }} key: ${{ secrets.PROD_SSH_KEY }}
script: | script: |
cd /var/www/koori-erp-prod cd /var/www/star-erp
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更 # 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
echo "REBUILD_NEEDED=true" echo "REBUILD_NEEDED=true"
@@ -163,7 +163,7 @@ jobs:
username: root username: root
key: ${{ secrets.PROD_SSH_KEY }} key: ${{ secrets.PROD_SSH_KEY }}
script: | script: |
cd /var/www/koori-erp-prod cd /var/www/star-erp
chown -R 1000:1000 . chown -R 1000:1000 .
# 檢查是否需要重建 # 檢查是否需要重建
@@ -173,7 +173,7 @@ jobs:
else else
echo "⚡ 無 Docker 檔案變更,僅重載服務..." echo "⚡ 無 Docker 檔案變更,僅重載服務..."
# 確保容器正在運行(若未運行則啟動) # 確保容器正在運行(若未運行則啟動)
if ! docker ps --format '{{.Names}}' | grep -q 'koori-erp-laravel'; then if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
echo "容器未運行,正在啟動..." echo "容器未運行,正在啟動..."
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
else else
@@ -181,9 +181,9 @@ jobs:
fi fi
fi fi
echo "容器狀態:" && docker ps --filter "name=koori-erp-laravel" echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
docker exec -u 1000:1000 -w /var/www/html koori-erp-laravel sh -c " docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
composer install --no-dev --optimize-autoloader && composer install --no-dev --optimize-autoloader &&
npm install && npm install &&
npm run build npm run build
@@ -193,4 +193,4 @@ jobs:
php artisan optimize && php artisan optimize &&
php artisan view:cache php artisan view:cache
" "
docker exec koori-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

View File

@@ -21,6 +21,7 @@ class ActivityLogController extends Controller
'App\Models\PurchaseOrder' => '採購單', 'App\Models\PurchaseOrder' => '採購單',
'App\Models\Warehouse' => '倉庫', 'App\Models\Warehouse' => '倉庫',
'App\Models\Inventory' => '庫存', 'App\Models\Inventory' => '庫存',
'App\Models\UtilityFee' => '公共事業費',
]; ];
} }

View File

@@ -179,6 +179,8 @@ class RoleController extends Controller
'purchase_orders' => '採購單管理', 'purchase_orders' => '採購單管理',
'users' => '使用者管理', 'users' => '使用者管理',
'roles' => '角色與權限', 'roles' => '角色與權限',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
]; ];
$result = []; $result = [];

View File

@@ -66,7 +66,22 @@ class UtilityFeeController extends Controller
'description' => 'nullable|string', '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(); return redirect()->back();
} }
@@ -81,14 +96,81 @@ class UtilityFeeController extends Controller
'description' => 'nullable|string', 'description' => 'nullable|string',
]); ]);
// Capture old attributes before update
$oldAttributes = $utility_fee->getAttributes();
$utility_fee->update($validated); $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(); return redirect()->back();
} }
public function destroy(UtilityFee $utility_fee) 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(); $utility_fee->delete();
return redirect()->back(); return redirect()->back();
} }
} }

View File

@@ -4,12 +4,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class UtilityFee extends Model class UtilityFee extends Model
{ {
use HasFactory, LogsActivity; use HasFactory;
protected $fillable = [ protected $fillable = [
'transaction_date', 'transaction_date',
@@ -23,12 +21,4 @@ class UtilityFee extends Model
'transaction_date' => 'date:Y-m-d', 'transaction_date' => 'date:Y-m-d',
'amount' => 'decimal:2', 'amount' => 'decimal:2',
]; ];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
} }

View File

@@ -15,6 +15,7 @@
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"spatie/laravel-activitylog": "^4.10", "spatie/laravel-activitylog": "^4.10",
"spatie/laravel-permission": "^6.24", "spatie/laravel-permission": "^6.24",
"stancl/jobpipeline": "^1.8",
"stancl/tenancy": "^3.9", "stancl/tenancy": "^3.9",
"tightenco/ziggy": "^2.6" "tightenco/ziggy": "^2.6"
}, },

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "131ea6e8cc24a6a55229afded6bd9014", "content-hash": "46092572c41c587bf3e7fc53465e5b56",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",

View File

@@ -63,6 +63,16 @@ class PermissionSeeder extends Seeder
// 系統日誌 // 系統日誌
'system.view_logs', 'system.view_logs',
// 公共事業費管理
'utility_fees.view',
'utility_fees.create',
'utility_fees.edit',
'utility_fees.delete',
// 會計報表
'accounting.view',
'accounting.export',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -90,7 +100,10 @@ class PermissionSeeder extends Seeder
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete', 'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete', 'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
'users.view', 'users.create', 'users.edit', 'users.view', 'users.create', 'users.edit',
'users.view', 'users.create', 'users.edit',
'system.view_logs', 'system.view_logs',
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
'accounting.view', 'accounting.export',
]); ]);
// warehouse-manager 管理庫存與倉庫 // warehouse-manager 管理庫存與倉庫
@@ -115,6 +128,8 @@ class PermissionSeeder extends Seeder
'inventory.view', 'inventory.view',
'vendors.view', 'vendors.view',
'warehouses.view', 'warehouses.view',
'utility_fees.view',
'accounting.view',
]); ]);
// 將現有使用者設為 super-admin如果存在的話 // 將現有使用者設為 super-admin如果存在的話

View File

@@ -101,6 +101,10 @@ const fieldLabels: Record<string, string> = {
invoice_date: '發票日期', invoice_date: '發票日期',
invoice_amount: '發票金額', invoice_amount: '發票金額',
last_price: '供貨價格', last_price: '供貨價格',
// Utility Fee fields
transaction_date: '費用日期',
category: '費用類別',
amount: '金額',
}; };
// Purchase Order Status Map // Purchase Order Status Map
@@ -325,7 +329,18 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
<TableBody> <TableBody>
{filteredKeys.some(key => !isSnapshotField(key)) ? ( {filteredKeys.some(key => !isSnapshotField(key)) ? (
filteredKeys 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) => { .map((key) => {
const oldValue = old[key]; const oldValue = old[key];
const newValue = attributes[key]; const newValue = attributes[key];

View File

@@ -63,7 +63,7 @@ export default function LogTable({
// Try to find a name in snapshot, attributes or old values // Try to find a name in snapshot, attributes or old values
// Priority: snapshot > specific name fields > generic name > code > ID // 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 = ''; let subjectName = '';
// Special handling for Inventory: show "Warehouse - Product" // Special handling for Inventory: show "Warehouse - Product"

View File

@@ -27,6 +27,7 @@ import { getDateRange, formatDateWithDayOfWeek } from "@/utils/format";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Can } from "@/Components/Permission/Can";
interface Record { interface Record {
id: string; id: string;
@@ -135,13 +136,15 @@ export default function AccountingReport({ records, summary, filters }: PageProp
<p className="text-gray-500 mt-1"></p> <p className="text-gray-500 mt-1"></p>
</div> </div>
<Button <Can permission="accounting.export">
onClick={handleExport} <Button
variant="outline" onClick={handleExport}
className="button-outlined-primary gap-2" variant="outline"
> className="button-outlined-primary gap-2"
<Download className="h-4 w-4" /> CSV >
</Button> <Download className="h-4 w-4" /> CSV
</Button>
</Can>
</div> </div>
{/* Filters with Quick Date Range */} {/* Filters with Quick Date Range */}
@@ -224,7 +227,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
onClick={handleFilter} onClick={handleFilter}
className="button-filled-primary h-9 px-6 gap-2" className="button-filled-primary h-9 px-6 gap-2"
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -74,6 +74,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
'adjust': '新增 / 調整', 'adjust': '新增 / 調整',
'transfer': '調撥', 'transfer': '調撥',
'safety_stock': '安全庫存設定', 'safety_stock': '安全庫存設定',
'export': '匯出',
}; };
return map[action] || action; return map[action] || action;

View File

@@ -81,6 +81,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
'adjust': '新增 / 調整', 'adjust': '新增 / 調整',
'transfer': '調撥', 'transfer': '調撥',
'safety_stock': '安全庫存設定', 'safety_stock': '安全庫存設定',
'export': '匯出',
}; };
return map[action] || action; return map[action] || action;

View File

@@ -147,7 +147,9 @@ Route::middleware('auth')->group(function () {
// 系統管理 // 系統管理
Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () { Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () {
Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report'); Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report');
Route::get('/export', [AccountingReportController::class, 'export'])->name('accounting.export'); Route::get('/export', [AccountingReportController::class, 'export'])
->middleware('permission:accounting.export')
->name('accounting.export');
}); });
// 系統管理 // 系統管理