# Design Spec: 支出追蹤 & 報銷 Expense Tracking & Reimbursement **日期:** 2026-05-29 **作者:** Chris Chen **狀態:** 待核准 **對應規劃:** `docs/PLANNING.md §3.6d`、`docs/DB_SCHEMA.md §8` **前置模組:** 奉獻追蹤(`docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md`,已合併) --- ## 1. 範疇 本 spec 規劃**支出追蹤 & 報銷**模組,涵蓋 PLANNING 勾選清單全部五項: 1. **支出類別設定** — `ExpenseCategoryGroup`(11 大類)+ `ExpenseSubCategory`(~38 子項)CRUD + 種子 2. **廠商直接付款記錄** — `Expense`(`Type=VendorPayment`),含支票號碼,建立即 `Paid` 3. **同工代墊報銷申請** — `Expense`(`Type=StaffReimbursement`),含收據照片上傳 + **同工自助提交** 4. **財務審核流程** — 狀態機 `Draft → PendingApproval → Approved → Paid`(或 `Rejected`) 5. **月結對帳表** — `MonthlyStatement`(期初 + 奉獻 − 支出 = 帳面期末,對比銀行對帳單) **明確不在本次範圍:** - 事工預算 `MinistryBudget`(Phase 3,schema §15 已預留,本次不建) - Azure Blob 實際接線(本次用本機磁碟 `IFileStorage` 實作,介面預留 Blob 替換) - Capacitor Camera 原生拍照(本次用網頁 file input,桌機 + 行動網頁皆可) - 報表 / 圖表(PLANNING §3.9,另模組) --- ## 2. 關鍵決策摘要 | # | 決策 | 選擇 | |---|------|------| | D1 | 收據儲存 | **`IFileStorage` 抽象 + 本機磁碟實作**。商業邏輯依賴介面;未來換 Azure Blob 只需新增一個實作,不動 service。下載由 API 驗權後串流(取代 dev 尚無的 SAS URL)。 | | D2 | 報銷提交範圍 | **同工自助 + 財務代建並行**。任何登入者可建立/提交/查詢自己的報銷;finance/super_admin 可代建並全覽審核。 | | D3 | 月結納入本次 | **是**,五項一次交付。`MonthlyStatement` 彙總已完成的 `Giving` + 本模組 `Expense`。 | | D4 | Expense 刪除策略 | **軟刪除**(`SoftDeleteEntity` + 全域 query filter)。依 DB_SCHEMA §8 `Expense` 含 `IsDeleted`;與 `Giving` 的實刪不同。 | | D5 | 月結支出認列基礎 | **僅 `Status=Paid` 計入當月 `TotalExpenses`**(現金基礎;`Approved` 未付款者不計)。依 DB_SCHEMA §8 註解。**已經 Chris 確認。** | | D6 | 類別刪除 | **軟停用** `IsActive=false`(保留歷史 Expense 的 FK),沿用 `GivingCategory` 作法。 | | D7 | 授權 | 自助寫入限「提交者本人 + Draft 狀態」;審核/廠商付款/類別/月結限 `finance,super_admin`;pastor 全支出唯讀。對應 PLANNING §3.6d 權限表。 | --- ## 3. 資料模型 依 `DB_SCHEMA.md §8`,新增 4 個 entity。 > **基底類別慣例:** 既有 `GivingCategory` 雖 schema §7 未列審計欄,程式實作仍繼承 `AuditableEntity`(審計欄由 `AuditSaveChangesInterceptor` 自動戳記)。本模組 category 表沿用同一慣例以保持一致。 ### 3.1 ExpenseCategoryGroup(支出大類,Level 1)— `AuditableEntity` | 欄位 | 型別 | 說明 | |------|------|------| | Id | int PK | | | Name_en | varchar(200) NOT NULL | | | Name_zh | varchar(200)? | | | SortOrder | int NOT NULL DEFAULT 0 | | | IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用 | | + AuditableEntity | | CreatedAt/By, UpdatedAt/By | ### 3.2 ExpenseSubCategory(支出子項目,Level 2)— `AuditableEntity` | 欄位 | 型別 | 說明 | |------|------|------| | Id | int PK | | | GroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id(Restrict)| | Name_en | varchar(200) NOT NULL | | | Name_zh | varchar(200)? | | | SortOrder | int NOT NULL DEFAULT 0 | | | IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用 | | + AuditableEntity | | | ### 3.3 Expense(支出記錄)— `SoftDeleteEntity` | 欄位 | 型別 | 說明 | |------|------|------| | Id | int PK | | | MinistryId | int NOT NULL | FK → Ministries.Id(Restrict)| | CategoryGroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id(Restrict)| | SubCategoryId | int NOT NULL | FK → ExpenseSubCategories.Id(Restrict)| | Type | varchar(30) NOT NULL | 'VendorPayment' \| 'StaffReimbursement' | | Status | varchar(30) NOT NULL DEFAULT 'Draft' | 見 §5 狀態機 | | Amount | decimal(18,2) NOT NULL | | | Description | varchar(500) NOT NULL | | | VendorName | varchar(200)? | VendorPayment 用 | | MemberId | int? | FK → Members.Id;StaffReimbursement 指向代墊同工(SetNull)| | CheckNumber | varchar(50)? | 付款支票號碼 | | ExpenseDate | date NOT NULL | | | ReceiptBlobPath | varchar(500)? | 收據路徑(透過 `IFileStorage`)| | Notes | text? | | | SubmittedBy | varchar(450)? | FK → AspNetUsers.Id(提交報銷者)| | SubmittedAt | timestamp? | | | ReviewedBy | varchar(450)? | FK → AspNetUsers.Id(審核人)| | ReviewedAt | timestamp? | | | ReviewNotes | varchar(500)? | 審核/退回備注 | | PaidAt | timestamp? | | | PaidBy | varchar(450)? | FK → AspNetUsers.Id | | + SoftDeleteEntity | | IsDeleted/DeletedAt/DeletedBy + AuditableEntity | > **欄位對齊:** schema §8 `PayeeType`/`PayeeName` 在本實作以 `Type` + `VendorName`(廠商)/ `MemberId`(同工)表達——`StaffReimbursement` 的收款人即 `MemberId` 指向的同工,不另存自由文字姓名,避免與 Member 資料重複(沿用 Giving 模組 D3「giver 永遠是 Member」的一致原則)。 ### 3.4 MonthlyStatement(月底對帳表)— `AuditableEntity` | 欄位 | 型別 | 說明 | |------|------|------| | Id | int PK | | | Year | int NOT NULL | | | Month | int NOT NULL | 1–12 | | OpeningBalance | decimal(18,2) NOT NULL | 期初餘額(手輸)| | TotalGiving | decimal(18,2) NOT NULL | 伺服器算:該月 Giving 合計 | | TotalOtherIncome | decimal(18,2) NOT NULL DEFAULT 0 | 其他收入(手輸)| | TotalExpenses | decimal(18,2) NOT NULL | 伺服器算:該月 `Status=Paid` 且未刪支出合計 | | CalculatedClosingBalance | decimal(18,2) NOT NULL | = Opening + Giving + OtherIncome − Expenses | | BankStatementBalance | decimal(18,2) NOT NULL | 銀行對帳單期末(手輸)| | Difference | decimal(18,2) NOT NULL | = Calculated − Bank(目標 0)| | Notes | text? | | | IsFinalized | bool NOT NULL DEFAULT false | 定稿後鎖定不可修改 | | FinalizedAt | timestamp? | | | FinalizedBy | varchar(450)? | FK → AspNetUsers.Id | | + AuditableEntity | | | | **UNIQUE** | (Year, Month) | 每月一份 | ### 3.5 EF Core 設定(`AppDbContext.OnModelCreating`) - 4 個 `DbSet<>`;`Name_*` maxlength;decimal 沿用全域 `decimal(18,2)` 慣例。 - `Expense` 全域 query filter `e => !e.IsDeleted`(沿用既有 Member/PrayerRequest 慣例)。 - `MonthlyStatement.HasIndex(Year, Month).IsUnique()`。 - 索引(對應 DB_SCHEMA §17): - `idx_expenses_ministry` → `(MinistryId)` - `idx_expenses_status` → `(Status)` filter `IsDeleted=false` - `idx_expenses_date` → `(ExpenseDate)` - FK 行為:`MinistryId`/`CategoryGroupId`/`SubCategoryId` = **Restrict**(有支出時類別/事工不可實刪,只能軟停用);`MemberId` = **SetNull**;`SubCategory → Group` = **Restrict**。 ### 3.6 Seed(`DbSeeder.SeedExpenseCategoriesAsync`) 沿用既有 code-based 風格(非 `HasData`),依 DB_SCHEMA §8 完整清單植入 **11 大類 + 38 子項**: ``` 1 Equipment 設備: Purchase 購置 · Rental 租借 · Maintenance & Repair 維修 2 Consumables 消耗品: Batteries 電池 · Accessories 配件 · Cleaning Supplies 清潔用品 · Office Supplies 文具 3 Food & Beverage 餐飲: Catering 出餐費用 · Food Ingredients 食材採購 · Utensils 器具 · Consumables 消耗品 4 Training 培訓: Course Fees 課程費用 · Books 書籍 · Conference 研討會 · Travel 差旅 5 Materials 教材: Printing 印刷費用 · Craft Supplies 手工材料 · Copyright & Licensing 版權購買 6 Facility 場地: Rent 場地租金 · Utilities 水電 · Property Insurance 財產保險 · Decoration 裝飾 7 Printing 印刷: Bulletins 週報 · Order of Service 程序單 · Posters 海報 8 Missions 宣教: Offering Transfer 奉獻轉帳 · Missionary Support 宣教士支援 · Travel 差旅 9 Benevolence 關懷救助: Emergency Aid 急難救助 · Condolence Gifts 慰問禮品 · Visit Expenses 探訪費用 10 Other 其他: Miscellaneous 雜支 11 Personnel 人事: Salary & Wages 薪資 · Payroll Taxes 薪資稅費 · Employee Benefits 員工福利 · Workers Compensation 勞工保險 · Honorarium 酬庸 · Staff Training 同工進修 · Contract Labor 外包勞務 ``` 冪等:以 `Name_en` 為鍵 `AnyAsync` 檢查後才插入(沿用 `SeedGivingCategoriesAsync` 模式)。 ### 3.7 Migration 一支 EF migration `AddExpenseModule`,涵蓋 4 表 + 索引 + FK。 --- ## 4. 收據檔案儲存(`IFileStorage`) ### 4.1 介面(新增 `Services/Storage/IFileStorage.cs`) ```csharp public interface IFileStorage { Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default); Task OpenReadAsync(string relativePath, CancellationToken ct = default); Task DeleteAsync(string relativePath, CancellationToken ct = default); } ``` ### 4.2 本機磁碟實作(`LocalDiskFileStorage`) - 根目錄來自 config `Storage:LocalRoot`(預設 `App_Data/storage`,相對於 ContentRoot)。 - 防目錄穿越:正規化 `relativePath`,拒絕 `..`/絕對路徑。 - `Program.cs` 註冊 `services.AddScoped()`。 - `appsettings.json` 加 `"Storage": { "LocalRoot": "App_Data/storage" }`;`App_Data/storage` 加進 `.gitignore`。 ### 4.3 路徑慣例 ``` finance/receipts/{year}/{month}/{expenseId}-{sanitizedFilename} ``` ### 4.4 上傳 / 下載端點(在 `ExpensesController`) - `POST /api/expenses/{id}/receipt`(multipart/form-data,`IFormFile file`): - 驗 MIME:`image/jpeg`、`image/png`、`image/webp`、`application/pdf`;大小上限(如 10 MB)。 - 權限:提交者本人(自己的報銷)或 finance/super_admin。 - 存檔 → 回填 `Expense.ReceiptBlobPath` → 回 200。若已有舊檔則覆蓋並刪舊。 - `GET /api/expenses/{id}/receipt`:驗權後 `IFileStorage.OpenReadAsync` 串流回傳(含 Content-Type)。權限同上 + pastor 唯讀。 --- ## 5. 狀態機 & 授權 ### 5.1 狀態流轉(伺服器端守門) ``` VendorPayment: finance 建立 ──► Paid(無審核;需 CheckNumber 或付款資訊) StaffReimbursement: Draft ─submit─► PendingApproval ─approve─► Approved ─pay─► Paid └──────────── reject ───────────► Rejected ``` - 合法轉換對照表(service 強制;非法轉換丟 `InvalidOperationException` → controller 409): | 動作 | 允許的起始狀態 | 結果狀態 | 角色 | |------|---------------|---------|------| | submit | Draft | PendingApproval | 提交者本人 | | approve | PendingApproval | Approved | finance, super_admin | | reject | PendingApproval | Rejected(記 `ReviewNotes`)| finance, super_admin | | pay | Approved | Paid(記 `CheckNumber`/`PaidAt`/`PaidBy`)| finance, super_admin | - `Rejected` 後是否可重新提交:本次**不允許**(終態);同工需另建新報銷。可列為日後增強。 ### 5.2 授權矩陣(對應 PLANNING §3.6d) | 操作 | 角色 | |------|------| | 提交報銷申請 / 編輯自己的 Draft / 上傳自己的收據 | 所有登入用戶(含 member)| | 查看自己的申請(`/api/expenses/mine`)| 提交者本人 | | 審核 approve / reject / pay | finance, super_admin | | 建立廠商直接付款 | finance, super_admin | | 查看所有支出(`/api/expenses`)| finance, super_admin(pastor 唯讀)| | 類別管理 | finance, super_admin | | 月底對帳 | finance, super_admin | - 自助寫入守門:service 比對 `Expense.SubmittedBy == CurrentUserId` 且 `Status=Draft`,否則 403/409。 - `CurrentUserId` 透過 `IHttpContextAccessor`(沿用既有 service 慣例)。 - 同工自助建立時 `MemberId` = 當前使用者 `AppUser.MemberId`(若有連結);`Type=StaffReimbursement`、`Status=Draft`、`SubmittedBy=自己`。 --- ## 6. API 介面 全部依角色授權。Controller thin → 委派 service,沿用 `PagedResult<>` 與錯誤轉換(`KeyNotFoundException`→404,狀態/鎖定衝突→409,跨人存取→403)。 ### 6.1 類別 `ExpenseCategoriesController` — `/api/expense-categories`(finance, super_admin) ``` GET /api/expense-categories?includeInactive=false 大類含巢狀子項 POST /api/expense-categories/groups 新增大類 PUT /api/expense-categories/groups/{id} DELETE /api/expense-categories/groups/{id} 軟停用 POST /api/expense-categories/subcategories 新增子項 PUT /api/expense-categories/subcategories/{id} DELETE /api/expense-categories/subcategories/{id} 軟停用 ``` ### 6.2 支出 `ExpensesController` — `/api/expenses` ``` GET /api/expenses?page&pageSize&search&ministryId&categoryGroupId&status&from&to finance 全覽分頁(含 pastor 唯讀) GET /api/expenses/mine?status&page&pageSize 同工查自己的報銷 GET /api/expenses/{id} 本人或 finance/pastor POST /api/expenses 建立(vendor 付款 或 報銷草稿) PUT /api/expenses/{id} 編輯(Draft 提交者 或 finance) DELETE /api/expenses/{id} 軟刪除(finance;或本人的 Draft) POST /api/expenses/{id}/submit Draft→PendingApproval(提交者) POST /api/expenses/{id}/approve finance POST /api/expenses/{id}/reject finance(body: reviewNotes) POST /api/expenses/{id}/pay finance(body: checkNumber, paidAt?) POST /api/expenses/{id}/receipt 上傳收據(multipart) GET /api/expenses/{id}/receipt 串流下載 ``` - `search` 比對 `Description` / `VendorName` / Member 姓名(中英)。 - `POST` 依 `Type` 分流:`VendorPayment` 由 finance 建立、直接 `Paid`、需 `VendorName`;`StaffReimbursement` 任何登入者建立、`Draft`。 #### POST /api/expenses 請求形狀 ```jsonc // 廠商付款(finance) { "type": "VendorPayment", "ministryId": 10, "categoryGroupId": 3, "subCategoryId": 8, "amount": 320.00, "description": "每週愛宴外燴", "vendorName": "ABC Catering", "checkNumber": "2051", "expenseDate": "2026-05-31", "notes": null } // 同工報銷草稿(任何登入者;伺服器自帶 SubmittedBy / MemberId) { "type": "StaffReimbursement", "ministryId": 4, "categoryGroupId": 2, "subCategoryId": 6, "amount": 45.50, "description": "敬拜團電池", "expenseDate": "2026-05-28", "notes": null } ``` ### 6.3 月結 `MonthlyStatementsController` — `/api/monthly-statements`(finance, super_admin) ``` GET /api/monthly-statements?year=2026 列表 GET /api/monthly-statements/{id} POST /api/monthly-statements 建立(伺服器算 Giving/Expense 合計) PUT /api/monthly-statements/{id} 更新手輸欄位 + 重算(未 finalize 才可) POST /api/monthly-statements/{id}/finalize 鎖定 ``` - `POST` body:`year`、`month`、`openingBalance`、`totalOtherIncome`、`bankStatementBalance`、`notes`。撞 `UNIQUE(Year,Month)` → 409。 - 建立與每次 `PUT` 時,伺服器**重算** `TotalGiving`、`TotalExpenses`、`CalculatedClosingBalance`、`Difference`(不信任前端)。 - finalize 後再 `PUT` → 409。 --- ## 7. 後端 Service 層 3 個 service(interface + impl,沿用 `GivingService` 風格:注入 `AppDbContext`、必要時 `IHttpContextAccessor` 取 `CurrentUserId`、手動 DTO mapping)。 - **`IExpenseCategoryService`** — 大類 + 子項 CRUD;DELETE = `IsActive=false`;`GET` 回巢狀結構(大類含子項,依 `includeInactive` 過濾)。 - **`IExpenseService`** — 核心: - `CreatePaged/GetMine/GetById`:投影 Ministry/Category/SubCategory/Member 名稱(沿用 `GivingService` 的字典批次查名)。 - `CreateAsync`:依 `Type` 設初始 `Status`(Vendor→Paid、Staff→Draft)與審計欄位。 - `UpdateAsync`:Draft(本人)或 finance 可改;其餘 409。 - 狀態動作 `SubmitAsync/ApproveAsync/RejectAsync/PayAsync`:各自驗起始狀態 + 角色 + 寫對應時間戳/操作人。 - `DeleteAsync`:軟刪除(攔截器或手動設 `IsDeleted`)。 - 收據 `SaveReceiptAsync/OpenReceiptAsync`:委派 `IFileStorage`,驗權。 - **`IMonthlyStatementService`** — 建立/更新時重算合計;`FinalizeAsync` 設 `IsFinalized`;finalize 後寫入守門。 - `TotalGiving = Σ Givings WHERE GivingDate 落在該月`。 - `TotalExpenses = Σ Expenses WHERE ExpenseDate 落在該月 AND Status='Paid' AND !IsDeleted`。 --- ## 8. 前端 依既有 feature 結構 `features/expense/{pages,components,services,models}`,standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + `ApiConfigService`。 ### 8.1 類別管理頁 `expense-categories-page`(finance) 左大類 / 右子項的雙層管理;新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 `giving-categories-page` 模式。 ### 8.2 支出總覽頁 `expenses-page`(finance) Kendo Grid(搜尋 / ministry / 大類 / 狀態 / 日期區間 / 分頁)。工具列: - 「+ 廠商付款」dialog:Ministry→大類→子項串聯下拉(選大類後子項自動篩選)、金額、廠商名、支票號、日期 → 建立即 Paid。 - 「+ 代建報銷」dialog:同上 + 選同工(復用 `GET /api/members?search=`)。 - 列動作(依狀態):approve / reject(填原因)/ pay(填支票號+日期)/ 看收據 / 軟刪除。 ### 8.3 我的報銷頁 `my-reimbursements-page`(所有登入者) 列出自己的報銷(狀態 badge)。「+ 新報銷」dialog:Ministry→大類→子項、金額、說明、日期、上傳收據(file input)。Draft 可編輯/刪除/提交;提交後唯讀看狀態與審核備注。 ### 8.4 月結對帳頁 `monthly-statement-page`(finance) 列表(年份篩選)+ 「+ 新月結」:選年月 → 伺服器回填 `TotalGiving`/`TotalExpenses` → 手輸 `OpeningBalance`/`TotalOtherIncome`/`BankStatementBalance` → 即時顯示 `CalculatedClosing` 與 `Difference`(目標 0 高亮)→ 儲存 / finalize 鎖定。 ### 8.5 API service / model 每頁一支 `*-api.service.ts` + `models/expense.model.ts`(DTO interface,對齊後端 DTO 命名與 giving model 風格)。 --- ## 9. 授權 & 導覽 - 後端:如 §5.2 / §6 各 controller `[Authorize]` 角色設定;自助端點 `[Authorize]`(僅需登入)+ service 內本人守門。 - 前端導覽(`UserPortalComponent` 內嵌側欄,沿用既有 role-gated section 機制): - **Finance 區塊**(finance/super_admin)新增:`Expenses`、`Expense Categories`、`Monthly Statement`。 - **新增所有登入者可見項**:`My Reimbursements`(放 Main 或新「Finance/個人」分組,僅需登入)。 - Routes 加入 `user-portal` children: - `finance/expenses`、`finance/expense-categories`、`finance/monthly-statement` → `RoleGuard data.roles=['finance','super_admin']`。 - `my-reimbursements` → 僅 `AuthGuard`。 - `getPageTitle` / `updatePageTitle` 對應補上新頁標題。 --- ## 10. 測試(`ROLAC.API.Tests`,InMemory provider) - **`ExpenseCategoryService`**:大類/子項 CRUD、DELETE 軟停用、有支出時類別不可實刪、巢狀查詢 + `includeInactive`。 - **`ExpenseService`**(重點): - Vendor 建立 → 直接 `Paid`。 - Staff 建立 → `Draft`、`SubmittedBy` 正確帶入。 - 狀態機合法路徑:submit→approve→pay;submit→reject。 - 非法轉換被拒(如 Draft 直接 approve、已 Paid 再 submit)→ 409。 - 自助守門:非本人改他人 Draft → 拒;改非 Draft → 拒。 - 軟刪除後不出現在查詢、不計入月結。 - 收據上傳/下載(以 fake/in-memory `IFileStorage` 驗)。 - **`MonthlyStatementService`**: - `TotalGiving`/`TotalExpenses` 伺服器重算正確(只算該月 + `Paid` + 未刪)。 - `CalculatedClosingBalance` / `Difference` 計算正確。 - `UNIQUE(Year,Month)` 撞 → 409。 - finalize 後修改被拒。 - **授權**:非 finance/super_admin 呼叫審核/廠商付款/月結 → 403;自助端點未登入 → 401。 --- ## 11. 風險 / 待確認 | # | 項目 | 說明 | |---|------|------| | R1 | Ministry 列表端點 | 前端串聯下拉需 ministries 清單。實作時確認既有 `GET /api/ministries`(或等價)是否存在;若無則一併補一個唯讀端點。 | | R2 | `AppUser.MemberId` 連結 | 同工自助 `MemberId` 取自登入帳號的 `MemberId`;若帳號未連結 Member,報銷仍可建立(`MemberId=null`),但個人對應較弱。需確認登入帳號普遍已連結 Member。 | | R3 | 本機磁碟在多實例/部署 | `LocalDiskFileStorage` 僅適合單機 dev。正式部署換 Azure Blob 實作(介面已預留)。`App_Data/storage` 需排除版控。 | | R4 | Personnel 類別可見性 | PLANNING §3.6d 提到事工領袖不可見人事類支出;本次支出總覽僅開放 finance/pastor/super_admin,故暫無洩漏面,但日後若開放事工領袖檢視需加類別過濾。 | | R5 | 月結與後補支出 | 月結合計在建立/PUT 當下快照;若事後再補當月支出,需手動 PUT 重算。本次以「PUT 重算」滿足,不做自動同步。 | --- ## 12. 交付順序建議 1. Entities + EF 設定 + Seed + migration(`AddExpenseModule`) 2. `IFileStorage` + `LocalDiskFileStorage` + DI/config 3. Service 層(3 個)+ 單元測試 4. Controllers + 授權 + 收據上傳/下載 5. 前端 models + api services 6. 類別管理頁 → 支出總覽頁 → 我的報銷頁 → 月結對帳頁 7. 導覽整合 + 端到端手動驗證