docs: add design spec for giving/donation tracking module
Manual giving module (Phase 1): giving category config, single-entry giving, and keyboard-first Sunday offering batch entry (OfferingSession) with finance-gated reconciliation. Client-buffered bulk submit (decision B), lock-after-submit, Member-or-anonymous givers with inline quick-add. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
|||||||
|
# Design Spec: 奉獻追蹤(手動)Giving / Donation Tracking
|
||||||
|
|
||||||
|
**日期:** 2026-05-28
|
||||||
|
**作者:** Chris Chen
|
||||||
|
**狀態:** 待核准
|
||||||
|
**對應規劃:** `docs/PLANNING.md §3.6 / §3.6c`、`docs/DB_SCHEMA.md §7`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 範疇
|
||||||
|
|
||||||
|
本 spec 只規劃**奉獻追蹤(手動)**模組,三項核心功能:
|
||||||
|
|
||||||
|
1. **奉獻類型設定** — `GivingCategory` CRUD(什一 / 一般 / 特別 / 建堂 / 宣教…)
|
||||||
|
2. **單筆奉獻記錄** — 單筆 `Giving`(現金 / 支票 / Zelle / PayPal 手動)
|
||||||
|
3. **主日奉獻袋批次輸入** — `OfferingSession` 鍵盤優先快速錄入 + 對帳
|
||||||
|
|
||||||
|
**明確不在本次範圍(之後另開 spec):**
|
||||||
|
- 支出追蹤 & 報銷、月結對帳表(屬另一模組)
|
||||||
|
- 年度 IRS 收據(`GivingReceipt`,PLANNING §3.7)
|
||||||
|
- 個人奉獻歷史查詢、奉獻摘要報表(PLANNING §3.9)
|
||||||
|
- 線上金流 Stripe / PayPal Checkout、定期奉獻(`GivingRecurringSchedule`,Phase 4)
|
||||||
|
- 收據照片上傳(奉獻無此需求;blob 儲存尚未建置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 關鍵決策摘要
|
||||||
|
|
||||||
|
| # | 決策 | 選擇 |
|
||||||
|
|---|------|------|
|
||||||
|
| D1 | 批次錄入儲存策略 | **B — 純前端緩衝、最後一次 bulk submit**。前端記憶體累積整批,提交時一次 `POST` 建立 Session + 所有 Giving(單一 transaction)。 |
|
||||||
|
| D2 | Session 鎖定 | **Submitted 後鎖定**。修改需 `finance` 角色 `reopen`(回 Draft、整批載回前端)→ 編輯 → 重新提交整批替換。所有變動進 Audit Log。 |
|
||||||
|
| D3 | 奇款人識別 | giver 永遠是 **Member 或匿名**(維持 DB_SCHEMA 不變,不加 GiverName 自由欄)。非匿名又查無此人時,批次介面可**快速新增 Member**。 |
|
||||||
|
| D4 | 快速新增教友欄位 | 快速表單**要求英文姓名**(`FirstName_en` / `LastName_en`),與現有 `Member` schema 必填一致;`Status` 預設 `Visitor`。 |
|
||||||
|
| D5 | 授權 | 奉獻所有寫入操作限 `finance` + `super_admin`(對應 PLANNING §4 權限矩陣)。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 資料模型
|
||||||
|
|
||||||
|
照 `DB_SCHEMA.md §7`,新增 3 個 entity,全部繼承 `AuditableEntity`(非軟刪除 —— 奉獻記錄為金錢帳目,刪除走實刪 + Audit Log;與 `Member` 的軟刪除不同)。
|
||||||
|
|
||||||
|
> **討論點(待確認):** DB_SCHEMA §7 的 Giving / OfferingSession 未列 `IsDeleted`。本 spec 採實刪 + Audit。若日後需保留刪除痕跡,可改繼承 `SoftDeleteEntity`,但會影響月結加總邏輯(需排除 `IsDeleted`)。本次維持實刪。
|
||||||
|
|
||||||
|
### 3.1 GivingCategory(奉獻類型)
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Id | int PK | |
|
||||||
|
| Name_en | varchar(200) NOT NULL | e.g. 'Tithe' |
|
||||||
|
| Name_zh | varchar(200)? | e.g. '什一奉獻' |
|
||||||
|
| Description_en | varchar(500)? | |
|
||||||
|
| Description_zh | varchar(500)? | |
|
||||||
|
| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用(`IsActive=false`),不實刪,以保留歷史 Giving 的 FK |
|
||||||
|
| SortOrder | int NOT NULL DEFAULT 0 | |
|
||||||
|
| + AuditableEntity 欄位 | | CreatedAt/By, UpdatedAt/By |
|
||||||
|
|
||||||
|
### 3.2 OfferingSession(主日奉獻袋批次)
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Id | int PK | |
|
||||||
|
| SessionDate | date NOT NULL **UNIQUE** | 一個主日只有一個 Session |
|
||||||
|
| Status | varchar(20) NOT NULL DEFAULT 'Draft' | 'Draft' \| 'Submitted' \| 'Reconciled' |
|
||||||
|
| CashTotal | decimal(18,2) NOT NULL DEFAULT 0 | 財務人工清點現金總額 |
|
||||||
|
| CheckTotal | decimal(18,2) NOT NULL DEFAULT 0 | 支票清點總額 |
|
||||||
|
| SystemTotal | decimal(18,2) NOT NULL DEFAULT 0 | **伺服器**計算的明細加總 |
|
||||||
|
| Difference | decimal(18,2) NOT NULL DEFAULT 0 | = (CashTotal + CheckTotal) − SystemTotal,目標 0 |
|
||||||
|
| Notes | text? | |
|
||||||
|
| SubmittedAt / SubmittedBy | timestamp? / varchar(450)? | |
|
||||||
|
| ReconciledAt / ReconciledBy | timestamp? / varchar(450)? | |
|
||||||
|
| + AuditableEntity 欄位 | | |
|
||||||
|
|
||||||
|
> **Reconciled 狀態用途:** 本次仍實作 `submit`(Draft→Submitted)。`Reconciled` 作為日後月結對帳模組對接的終態保留欄位,本次 UI 不主動設定(只在 schema 預留)。
|
||||||
|
|
||||||
|
### 3.3 Giving(奉獻記錄)
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Id | int PK | |
|
||||||
|
| MemberId | int? | FK → Members.Id;null = 匿名 |
|
||||||
|
| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id |
|
||||||
|
| OfferingSessionId | int? | FK → OfferingSessions.Id;null = 非批次單筆 |
|
||||||
|
| Amount | decimal(18,2) NOT NULL | IRS 收據用金額 |
|
||||||
|
| PaymentMethod | varchar(20) NOT NULL | 'Cash' \| 'Check' \| 'Zelle' \| 'PayPal' \| 'Other' |
|
||||||
|
| CheckNumber | varchar(50)? | PaymentMethod=Check 時必填 |
|
||||||
|
| ZelleReferenceCode | varchar(100)? | PaymentMethod=Zelle |
|
||||||
|
| PayPalTransactionId | varchar(100)? | PaymentMethod=PayPal |
|
||||||
|
| GivingDate | date NOT NULL | 批次時 = SessionDate |
|
||||||
|
| IsAnonymous | bool NOT NULL DEFAULT false | |
|
||||||
|
| Notes | varchar(500)? | |
|
||||||
|
| + AuditableEntity 欄位 | | |
|
||||||
|
|
||||||
|
> Phase 4 欄位(`GrossAmount` / `FeeAmount` / `StripePaymentIntentId` / `GivingRecurringScheduleId`)本次**不建**,待線上金流模組再加,屆時為純新增欄位,不影響本次結構。
|
||||||
|
|
||||||
|
### 3.4 EF Core 設定(`AppDbContext.OnModelCreating`)
|
||||||
|
|
||||||
|
- 新增 3 個 `DbSet<GivingCategory>` / `<OfferingSession>` / `<Giving>`。
|
||||||
|
- fluent 設定:各 `Name_*` / `Description_*` maxlength;decimal 欄位 `decimal(18,2)`(沿用既有全域 decimal 慣例,或逐欄設定)。
|
||||||
|
- `OfferingSession`:`HasIndex(SessionDate).IsUnique()`。
|
||||||
|
- `Giving` 索引(對應 DB_SCHEMA §17):
|
||||||
|
- `idx_givings_member_date` → `(MemberId, GivingDate)`
|
||||||
|
- `idx_givings_session` → `(OfferingSessionId)` filter not null
|
||||||
|
- `idx_givings_date` → `(GivingDate)`
|
||||||
|
- FK 行為:
|
||||||
|
- `Giving.GivingCategoryId` → `Restrict`(類型有奉獻時不可實刪,只能軟停用)
|
||||||
|
- `Giving.MemberId` → `SetNull`
|
||||||
|
- `Giving.OfferingSessionId` → `Cascade`(刪 Session 連帶刪明細;一般不會發生,僅資料一致性)
|
||||||
|
|
||||||
|
### 3.5 Seed(`DbSeeder`)
|
||||||
|
|
||||||
|
新增 `SeedGivingCategoriesAsync`(沿用既有 code-based 風格,非 `HasData`):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Tithe / 什一奉獻
|
||||||
|
2. General Offering / 一般奉獻
|
||||||
|
3. Special Offering / 特別奉獻
|
||||||
|
4. Building Fund / 建堂基金
|
||||||
|
5. Mission / 宣教奉獻
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Migration
|
||||||
|
|
||||||
|
一支 EF migration(如 `AddGivingModule`),涵蓋 3 表 + 索引 + FK。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 介面
|
||||||
|
|
||||||
|
全部 `[Authorize(Roles = "finance,super_admin")]`。Controller 維持 thin → 委派 service,沿用既有 `PagedResult<>` 與錯誤轉換慣例(`KeyNotFoundException`→404,鎖定衝突→409)。
|
||||||
|
|
||||||
|
### 4.1 奉獻類型 `GivingCategoriesController` — `/api/giving-categories`
|
||||||
|
```
|
||||||
|
GET /api/giving-categories?includeInactive=false 全部(預設僅 active)
|
||||||
|
POST /api/giving-categories
|
||||||
|
PUT /api/giving-categories/{id}
|
||||||
|
DELETE /api/giving-categories/{id} 軟停用 IsActive=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 單筆奉獻 `GivingsController` — `/api/givings`
|
||||||
|
```
|
||||||
|
GET /api/givings?page&pageSize&search&categoryId&from&to 分頁列表
|
||||||
|
GET /api/givings/{id}
|
||||||
|
POST /api/givings 單筆,OfferingSessionId=null
|
||||||
|
PUT /api/givings/{id}
|
||||||
|
DELETE /api/givings/{id}
|
||||||
|
```
|
||||||
|
- `search` 比對 Member 姓名(中英)/ CheckNumber / Notes。
|
||||||
|
- 屬於某 Session(`OfferingSessionId != null`)的 Giving,若該 Session 已 `Submitted`,單筆 PUT/DELETE 一律拒絕(409)—— 必須走 Session `reopen`。
|
||||||
|
|
||||||
|
### 4.3 批次 `OfferingSessionsController` — `/api/offering-sessions`
|
||||||
|
```
|
||||||
|
GET /api/offering-sessions?page&pageSize&from&to 歷次 Session 列表
|
||||||
|
GET /api/offering-sessions/{id} 含完整明細 + 小計(reopen/檢視用)
|
||||||
|
POST /api/offering-sessions 一次建立整批(見 §6)
|
||||||
|
POST /api/offering-sessions/{id}/reopen Submitted→Draft(finance,進 Audit)
|
||||||
|
PUT /api/offering-sessions/{id} 整批替換明細 + 總額,重新 Submitted
|
||||||
|
GET /api/offering-sessions/check-date?date=... 檢查該日期是否已有 Session
|
||||||
|
```
|
||||||
|
- 批次教友搜尋:**復用** `GET /api/members?search=`(已支援中英文,見 `MemberService`)。
|
||||||
|
- 批次內快速新增教友:**復用** `POST /api/members`(現有 `CreateMemberRequest`)。
|
||||||
|
|
||||||
|
#### POST /api/offering-sessions 請求形狀
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"sessionDate": "2026-05-31",
|
||||||
|
"cashTotal": 1250.00,
|
||||||
|
"checkTotal": 800.00,
|
||||||
|
"notes": "...",
|
||||||
|
"givings": [
|
||||||
|
{ "memberId": 12, "givingCategoryId": 1, "amount": 100.00,
|
||||||
|
"paymentMethod": "Cash", "isAnonymous": false, "notes": null },
|
||||||
|
{ "memberId": null, "givingCategoryId": 2, "amount": 50.00,
|
||||||
|
"paymentMethod": "Cash", "isAnonymous": true },
|
||||||
|
{ "memberId": 8, "givingCategoryId": 1, "amount": 300.00,
|
||||||
|
"paymentMethod": "Check", "checkNumber": "1043" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
回傳建立後的 Session(含 `id`、伺服器算的 `systemTotal`、`difference`、`status="Submitted"`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 後端 Service 層
|
||||||
|
|
||||||
|
3 個 service(interface + impl,沿用 `MemberService` 風格:建構式注入 `AppDbContext` + `IHttpContextAccessor`、`CurrentUserId`、手動 DTO mapping)。
|
||||||
|
|
||||||
|
- **`IGivingCategoryService`** — CRUD;DELETE = 設 `IsActive=false`。
|
||||||
|
- **`IGivingService`** — 單筆 CRUD + 分頁查詢;寫入前檢查所屬 Session 鎖定狀態。
|
||||||
|
- **`IOfferingSessionService`** — 核心:
|
||||||
|
- `CreateAsync(request)`:單一 transaction 內建立 `OfferingSession`(Status=Submitted)+ 所有 `Giving`(`OfferingSessionId` 回填、`GivingDate=SessionDate`);**伺服器重算** `SystemTotal = Σ amount`、`Difference`;`SubmittedAt/By` 設值。`SessionDate` 撞 unique → 409。
|
||||||
|
- `GetByIdAsync(id)`:含明細投影。
|
||||||
|
- `ReopenAsync(id)`:僅 `Submitted` 可 reopen → `Draft`,清 `SubmittedAt/By`。
|
||||||
|
- `ReplaceAsync(id, request)`:僅 `Draft` 可改;刪舊明細、插新明細、重算、回 `Submitted`。
|
||||||
|
- **鎖定守門**:對已 `Submitted` 的修改丟 `InvalidOperationException` → controller 轉 409。
|
||||||
|
- 金額一律以伺服器加總為準(不信任前端 `systemTotal`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 批次錄入流程(前端,核心體驗)
|
||||||
|
|
||||||
|
頁面:`features/giving/pages/offering-session-page`。採決策 D1(純前端緩衝)。
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 選日期(預設今天)→ 呼叫 check-date 確認當日尚無 Session
|
||||||
|
2. 逐筆快速錄入(全部存在前端記憶體 buffer)
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 搜尋教友:[即時下拉] ← 復用 GET /api/members │
|
||||||
|
│ 類型:[GivingCategory ▼] │
|
||||||
|
│ 付款:(●現金 ○支票 ○Zelle ○PayPal) │
|
||||||
|
│ 現金→信封號(選填) / 支票→支票號(必填) │
|
||||||
|
│ 金額:[$____] 備註:[____] │
|
||||||
|
│ [匿名] [快速新增教友] [+ 新增(Enter)] │
|
||||||
|
│ ───────────────────────────────────────── │
|
||||||
|
│ 已錄入 N 筆 | 前端小計:$X,XXX.XX │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
- Tab 跳欄、Enter 新增;新增後焦點回搜尋框
|
||||||
|
- 樂觀 UI:新增即在右側清單顯示、即時更新前端小計
|
||||||
|
- 清單每列可即時改 / 刪(純前端操作)
|
||||||
|
3. 對帳步驟:輸入實點 CashTotal / CheckTotal
|
||||||
|
→ 前端顯示 Difference = (Cash+Check) − 前端小計(目標 0)
|
||||||
|
4. 「提交」→ 一次 POST /api/offering-sessions(整批)
|
||||||
|
→ 伺服器 transaction 建立 + 重算 + 回傳 → 鎖定
|
||||||
|
5. 編輯已提交:finance 在歷次列表點 reopen
|
||||||
|
→ GET 載回整批進 buffer → 編輯 → PUT 整批替換 → 重新 Submitted
|
||||||
|
```
|
||||||
|
|
||||||
|
**鍵盤優先要點:** 復用既有 `focus-navigator` / `init-focus` directives 與 `currency-input` / `drop-down-list` 共用元件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 快速新增教友(批次介面內)
|
||||||
|
|
||||||
|
- 搜尋無結果時出現「快速新增教友」→ inline 小 dialog。
|
||||||
|
- 收最少欄位:**`FirstName_en`、`LastName_en`(必填,決策 D4)**、`FirstName_zh`/`LastName_zh`(選填)、`PhoneCell`(選填)。
|
||||||
|
- `Status` 預設 `Visitor`。
|
||||||
|
- 呼叫現有 `POST /api/members`(`CreateMemberRequest`)→ 取回 `id` → 自動帶回當前錄入列、焦點移至金額。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端 — 其餘頁面
|
||||||
|
|
||||||
|
依既有 feature 結構 `features/giving/{pages,components,services,models}`,標準元件:standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + `ApiConfigService`。
|
||||||
|
|
||||||
|
### 8.1 奉獻類型管理頁 `giving-categories-page`
|
||||||
|
Kendo Grid 列表 + 新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 users/members 頁模式。
|
||||||
|
|
||||||
|
### 8.2 單筆奉獻頁 `givings-page`
|
||||||
|
Kendo Grid 列表(搜尋 / 日期區間 / 類型篩選 / 分頁)+ 單筆新增/編輯 dialog。付款方式切換時動態顯示支票號 / Zelle ref / PayPal txn 欄位。教友選擇復用 `GET /api/members?search=`。
|
||||||
|
|
||||||
|
### 8.3 API service / model
|
||||||
|
每頁一支 `*-api.service.ts` + `models/*.model.ts`(DTO interface,對齊後端 DTO 命名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 授權 & 導覽
|
||||||
|
|
||||||
|
- 後端:所有奉獻 controller `[Authorize(Roles="finance,super_admin")]`。
|
||||||
|
- 前端:沿用既有 role-gated sidebar nav(commit `bc67146` 的 Administration 機制),在 Finance/Administration 區塊新增「奉獻」分組,內含三頁,僅 `finance` / `super_admin` 可見。
|
||||||
|
- route guard 沿用 `core/guards` 既有角色守衛。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 測試
|
||||||
|
|
||||||
|
後端 `ROLAC.API.Tests`(沿用既有測試風格,InMemory provider):
|
||||||
|
|
||||||
|
- `GivingCategoryService`:CRUD、DELETE 軟停用、有奉獻時類型不可實刪。
|
||||||
|
- `GivingService`:單筆 CRUD、分頁/篩選、屬已 Submitted Session 的單筆寫入被拒(409)。
|
||||||
|
- `OfferingSessionService`(重點):
|
||||||
|
- 整批建立 → `SystemTotal` / `Difference` 伺服器重算正確(不信前端值)。
|
||||||
|
- `SessionDate` 撞 unique → 409。
|
||||||
|
- 已 `Submitted` 修改被拒(鎖定守門)。
|
||||||
|
- `reopen` → `replace` → 重新 `Submitted` 流程,明細正確替換。
|
||||||
|
- 匿名筆(`MemberId=null` / `IsAnonymous=true`)正確記錄。
|
||||||
|
- 授權:非 finance/super_admin 角色被 403。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 風險 / 待確認
|
||||||
|
|
||||||
|
| # | 項目 | 說明 |
|
||||||
|
|---|------|------|
|
||||||
|
| R1 | 實刪 vs 軟刪除(§3 討論點) | 本次 Giving/Session 採實刪 + Audit。若財務合規要求保留刪除痕跡,需改軟刪除並調整加總邏輯。**待 Chris 確認。** |
|
||||||
|
| R2 | 前端緩衝資料遺失(決策 B 的取捨) | 提交前瀏覽器崩潰 → 整批未存的資料遺失。可選緩解:`localStorage` 暫存草稿(本次列為可選,不強制)。 |
|
||||||
|
| R3 | reopen 整批替換的 Audit 粒度 | 整批替換會產生「刪舊 N 筆 + 插新 M 筆」的 Audit 記錄,而非逐筆 diff。對帳追溯時需理解此模型。 |
|
||||||
|
| R4 | Audit Log 基礎設施 | 現有 `AuditSaveChangesInterceptor` 是否已涵蓋這些新 entity 的 Create/Update/Delete,需在實作時確認;`AuditLog` 表本身屬另一模組,若尚未建置需補。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 交付順序建議
|
||||||
|
|
||||||
|
1. Entities + EF 設定 + Seed + migration
|
||||||
|
2. Service 層(3 個)+ 單元測試
|
||||||
|
3. Controllers + 授權
|
||||||
|
4. 前端 models + api services
|
||||||
|
5. 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁
|
||||||
|
6. 導覽整合 + 端到端手動驗證
|
||||||
Reference in New Issue
Block a user