393 lines
22 KiB
Markdown
393 lines
22 KiB
Markdown
# 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`(39 子項)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 大類 + 39 子項**:
|
||
|
||
```
|
||
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<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default);
|
||
Task<Stream?> 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<IFileStorage, LocalDiskFileStorage>()`。
|
||
- `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. 導覽整合 + 端到端手動驗證
|