# ROLAC — Database Schema Design

**版本:** v1.0 (2026-05-24)  
**資料庫:** PostgreSQL 15+  
**ORM:** Entity Framework Core 8 (Code-First Migrations)  
**命名慣例:** PascalCase 表名 / 欄位名（EF Core 預設映射到 snake_case PostgreSQL 欄位）

---

## 目錄

1. [設計原則](#1-設計原則)
2. [BaseEntity 模式](#2-baseentity-模式)
3. [Phase 1 — Authentication & Identity](#3-authentication--identity)
4. [Phase 1 — Member Management](#4-member-management)
5. [Phase 1 — Ministry（事工部門）](#5-ministry-事工部門)
6. [Phase 1 — CMS](#6-cms)
7. [Phase 1 — Giving & Donations（奉獻）](#7-giving--donations-奉獻)
8. [Phase 1 — Expense Tracking（支出）](#8-expense-tracking-支出)
9. [Phase 1 — Prayer Requests（代禱）](#9-prayer-requests-代禱)
10. [Phase 1 — Audit Log](#10-audit-log)
11. [Phase 1 — Notifications](#11-notifications)
12. [Phase 2 — Service Roster（服事表）](#12-service-roster-服事表)
13. [Phase 2 — Sunday Attendance（主日出席）](#13-sunday-attendance-主日出席)
14. [Phase 2 — Cell Groups（小組）](#14-cell-groups-小組)
15. [Phase 3 — Ministry Budget（事工預算）](#15-ministry-budget-事工預算)
16. [Seed Data](#16-seed-data)
17. [Indexes](#17-indexes)
18. [EF Core 設定摘要](#18-ef-core-設定摘要)

---

## 1. 設計原則

| 原則 | 說明 |
|------|------|
| **Code-First** | 全部透過 EF Core Migration 建立，不手寫 DDL |
| **Soft Delete** | 重要實體用 `IsDeleted` 標記刪除，不實際刪除 DB 記錄 |
| **Audit Trail** | 每個可修改實體均記錄 `CreatedAt / CreatedBy / UpdatedAt / UpdatedBy` |
| **Bilingual Fields** | 需要雙語的欄位加 `_en` / `_zh` 後綴（如 `Name_en` / `Name_zh`）|
| **Money** | 所有金額欄位使用 `decimal(18,2)` |
| **Phone** | `varchar(30)`（支援國際格式）|
| **EIN** | 不存入資料庫，由環境變數 `CHURCH_EIN` 提供 |
| **Azure Blob** | 圖片/PDF 只存 Blob 路徑（`varchar(500)`），不存 Base64 |
| **jsonb** | AuditLog 的 Before/After 用 PostgreSQL `jsonb` 儲存 |
| **IDs** | 一般實體用 `int` (SERIAL)；User 繼承 ASP.NET Identity 使用 `string (Guid)` |

---

## 2. BaseEntity 模式

大部分實體繼承以下基礎類別（透過 EF Core TPH 或 owned type 實現）：

```csharp
// 帶 Audit 的實體（可建立/修改）
public abstract class AuditableEntity
{
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = null!;  // FK → AppUser.Id
    public DateTime UpdatedAt { get; set; }
    public string UpdatedBy { get; set; } = null!;  // FK → AppUser.Id
}

// 帶軟刪除的實體
public abstract class SoftDeleteEntity : AuditableEntity
{
    public bool IsDeleted { get; set; } = false;
    public DateTime? DeletedAt { get; set; }
    public string? DeletedBy { get; set; }  // FK → AppUser.Id
}
```

---

## 3. Authentication & Identity

### AppUser（繼承 IdentityUser）

```
Table: AspNetUsers (ASP.NET Identity 預設名稱)
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | varchar(450) PK | Guid，由 Identity 管理 |
| UserName | varchar(256) | 使用者名稱（通常等同 Email）|
| NormalizedUserName | varchar(256) | 大寫版本，用於查詢 |
| Email | varchar(256) | 電子郵件 |
| NormalizedEmail | varchar(256) | |
| EmailConfirmed | bool | |
| PasswordHash | varchar(MAX) | |
| SecurityStamp | varchar(MAX) | |
| ConcurrencyStamp | varchar(MAX) | |
| PhoneNumber | varchar(256)? | |
| PhoneNumberConfirmed | bool | |
| TwoFactorEnabled | bool | |
| LockoutEnd | datetimeoffset? | |
| LockoutEnabled | bool | |
| AccessFailedCount | int | |
| **MemberId** | int? | FK → Member.Id（可為 null）|
| **LanguagePreference** | varchar(10) | 'en' \| 'zh-TW'，DEFAULT 'en' |
| **IsActive** | bool | DEFAULT true，停用帳號用 |
| **LastLoginAt** | timestamp? | 最後登入時間 |
| **CreatedAt** | timestamp | 帳號建立時間 |

> Identity 角色表（AspNetRoles, AspNetUserRoles）由 ASP.NET Identity 自動管理。

### AppRole（繼承 IdentityRole）

```
Table: AspNetRoles
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | varchar(450) PK | Guid |
| Name | varchar(256) | 角色名稱（見下方列表）|
| NormalizedName | varchar(256) | |
| ConcurrencyStamp | varchar(MAX) | |
| **Description** | varchar(500)? | 角色說明 |

**預設角色（Seed）：**

> ROLAC 為靈糧堂體制，無長老制。

| Name | 中文 | 說明 |
|------|------|------|
| super_admin | 系統管理員 | 所有權限 |
| pastor | 牧師 | 全覽教友與財務摘要 |
| board_member | 理事 | 教會治理委員，可查看財務摘要與教友概覽 |
| coworker_chair | 同工會主席 | 統籌各事工領袖，可管理服事與小組 |
| ministry_leader | 事工領袖 | 受 Ministry Scope 限制 |
| district_leader | 區長 | 管理轄下多個小組 |
| cell_leader | 小組長 | 僅限自身小組 |
| coworker | 同工 | 參與指定事工，可申請報銷 |
| finance | 財務同工 | 管理奉獻與支出 |
| secretary | 行政秘書 | 管理教友資料與排班 |
| worship_leader | 敬拜領袖 | 管理歌曲庫與歌單（Phase 暫緩）|
| member | 一般教友 | 查看個人資料與服事表 |
| visitor | 訪客 | 僅限公開頁面 |

### UserMinistry（Ministry Scope — 用戶管理哪個事工）

```
Table: UserMinistries
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| UserId | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| MinistryId | int NOT NULL | FK → Ministries.Id |
| AssignedAt | timestamp NOT NULL | |
| AssignedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (UserId, MinistryId) | 一個用戶不重複指派同一事工 |

### UserDevice（FCM 推播 Token）

```
Table: UserDevices
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| UserId | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| FcmToken | varchar(500) NOT NULL | Firebase Cloud Messaging token |
| Platform | varchar(20) NOT NULL | 'ios' \| 'android' \| 'web' |
| DeviceName | varchar(100)? | 裝置名稱（選填）|
| LastSeenAt | timestamp NOT NULL | |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |

---

## 4. Member Management

### Member（教友資料）

```
Table: Members
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| FirstName_en | varchar(100) NOT NULL | 英文名 |
| LastName_en | varchar(100) NOT NULL | 英文姓 |
| FirstName_zh | varchar(100)? | 中文名 |
| LastName_zh | varchar(100)? | 中文姓 |
| Gender | varchar(10)? | 'M' \| 'F' \| 'Other' |
| DateOfBirth | date? | 生日 |
| BaptismDate | date? | 受洗日期 |
| BaptismChurch | varchar(200)? | 受洗教會 |
| Email | varchar(200)? | |
| PhoneCell | varchar(30)? | 手機 |
| PhoneHome | varchar(30)? | 家電 |
| Address | varchar(500)? | 地址 |
| City | varchar(100)? | |
| State | varchar(50)? | |
| ZipCode | varchar(20)? | |
| Country | varchar(100) NOT NULL DEFAULT 'USA' | |
| PhotoBlobPath | varchar(500)? | Azure Blob 路徑 |
| Status | varchar(20) NOT NULL DEFAULT 'Member' | 'Member' \| 'Visitor' \| 'Inactive' \| 'Former' |
| LanguagePreference | varchar(10) NOT NULL DEFAULT 'en' | 'en' \| 'zh-TW' |
| JoinDate | date? | 加入教會日期 |
| Notes | text? | 內部備注 |
| FamilyUnitId | int? | FK → FamilyUnits.Id |
| IsDeleted | bool NOT NULL DEFAULT false | 軟刪除 |
| DeletedAt | timestamp? | |
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### FamilyUnit（家庭單元）

```
Table: FamilyUnits
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| FamilyName_en | varchar(200)? | 家庭英文名（如 "Chang Family"）|
| FamilyName_zh | varchar(200)? | 家庭中文名（如「張家」）|
| Notes | text? | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

> 一個 FamilyUnit 可有多個 Members（One-to-Many via `Member.FamilyUnitId`）

### MemberMinistry（教友服事事工關聯 — M:N）

```
Table: MemberMinistries
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MemberId | int NOT NULL | FK → Members.Id |
| MinistryId | int NOT NULL | FK → Ministries.Id |
| MinistryRole | varchar(50)? | 在事工中的角色（Leader/Member/Coordinator）|
| JoinedAt | date? | |
| Notes | varchar(200)? | |
| **UNIQUE** | (MemberId, MinistryId) | |

### MemberTag（教友標籤）

```
Table: MemberTags
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MemberId | int NOT NULL | FK → Members.Id |
| Tag | varchar(100) NOT NULL | e.g., 'NewBeliever', 'Volunteer', 'Youth' |
| **UNIQUE** | (MemberId, Tag) | |

---

## 5. Ministry（事工部門）

```
Table: Ministries
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Name_en | varchar(200) NOT NULL | |
| Name_zh | varchar(200)? | |
| Description_en | text? | |
| Description_zh | text? | |
| SortOrder | int NOT NULL DEFAULT 0 | 顯示排序 |
| IsActive | bool NOT NULL DEFAULT true | |
| **DefaultFunctionalClass** | varchar(20) NOT NULL DEFAULT 'Program' | IRS Form 990 功能性費用分類：'Program' \| 'ManagementGeneral' \| 'Fundraising'。Seed：Administration → 'ManagementGeneral'，其餘 → 'Program' |

---

## 6. CMS

### CmsPage（靜態頁面：關於我們、異象等）

```
Table: CmsPages
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Slug | varchar(100) UNIQUE NOT NULL | URL 識別碼，如 'about', 'vision' |
| Title_en | varchar(300) NOT NULL | |
| Title_zh | varchar(300)? | |
| Body_en | text? | Markdown 或 HTML |
| Body_zh | text? | |
| IsPublished | bool NOT NULL DEFAULT false | |
| PublishedAt | timestamp? | |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### Announcement（消息公告）

```
Table: Announcements
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Title_en | varchar(300) NOT NULL | |
| Title_zh | varchar(300)? | |
| Body_en | text NOT NULL | |
| Body_zh | text? | |
| PinnedUntil | date? | null = 不置頂 |
| ScheduledAt | timestamp? | null = 立即發佈 |
| IsPublished | bool NOT NULL DEFAULT false | |
| PublishedAt | timestamp? | |
| ImageBlobPath | varchar(500)? | 封面圖 Azure Blob 路徑 |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### SermonVideo（講道影片 — YouTube 嵌入）

```
Table: SermonVideos
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Title_en | varchar(300) NOT NULL | |
| Title_zh | varchar(300)? | |
| YouTubeVideoId | varchar(50) NOT NULL | YouTube 影片 ID（非完整 URL）|
| PreacherName | varchar(200)? | 講員姓名 |
| SermonDate | date NOT NULL | 講道日期 |
| Description_en | text? | |
| Description_zh | text? | |
| IsPublished | bool NOT NULL DEFAULT true | |
| SortOrder | int NOT NULL DEFAULT 0 | 顯示排序（新到舊）|
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### ContactInquiry（聯絡表單）

```
Table: ContactInquiries
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Name | varchar(200) NOT NULL | |
| Email | varchar(200) NOT NULL | |
| Phone | varchar(30)? | |
| Subject | varchar(300)? | |
| Message | text NOT NULL | |
| Language | varchar(10) NOT NULL DEFAULT 'en' | 表單填寫語言 |
| IsRead | bool NOT NULL DEFAULT false | |
| ReadAt | timestamp? | |
| ReadBy | varchar(450)? | FK → AspNetUsers.Id |
| AssignedTo | varchar(450)? | FK → AspNetUsers.Id |
| InternalNotes | text? | 後台同工備注 |
| CreatedAt | timestamp NOT NULL DEFAULT now() | |

---

## 7. Giving & Donations（奉獻）

### GivingCategory（奉獻類型）

```
Table: GivingCategories
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Name_en | varchar(200) NOT NULL | e.g., 'Tithe', 'General Offering', 'Building Fund' |
| Name_zh | varchar(200)? | e.g., '什一奉獻', '一般奉獻', '建堂基金' |
| Description_en | varchar(500)? | |
| Description_zh | varchar(500)? | |
| IsActive | bool NOT NULL DEFAULT true | |
| SortOrder | int NOT NULL DEFAULT 0 | |

### OfferingSession（主日奉獻袋批次作業）

```
Table: OfferingSessions
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| SessionDate | date NOT NULL | 主日日期 |
| 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 |
| Notes | text? | |
| SubmittedAt | timestamp? | |
| SubmittedBy | varchar(450)? | FK → AspNetUsers.Id |
| ReconciledAt | timestamp? | |
| ReconciledBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (SessionDate) | 一個主日只有一個 Session |

> **鍵盤優先 UI 設計：** 財務同工 Tab/Enter 跳欄逐筆輸入，每筆加入後右側即時更新加總；最後輸入 CashTotal / CheckTotal 人工清點金額，系統計算 Difference，目標為零後點「提交」。

### Giving（奉獻記錄）

```
Table: Givings
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| 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 收據的金額（PayPal 用 NetAmount）|
| GrossAmount | decimal(18,2)? | PayPal 手續費前總額 |
| FeeAmount | decimal(18,2)? | PayPal/Stripe 手續費 |
| PaymentMethod | varchar(20) NOT NULL | 'Cash' \| 'Check' \| 'Zelle' \| 'PayPal' \| 'Stripe' \| 'Other' |
| CheckNumber | varchar(50)? | 支票號碼 |
| ZelleReferenceCode | varchar(100)? | Zelle 參考碼（手動輸入）|
| PayPalTransactionId | varchar(100)? | PayPal 交易 ID |
| StripePaymentIntentId | varchar(200)? | Stripe PaymentIntent ID（Phase 4）|
| GivingDate | date NOT NULL | 奉獻日期 |
| IsAnonymous | bool NOT NULL DEFAULT false | |
| Notes | varchar(500)? | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

> **IRS 規則：** 匿名奉獻（`IsAnonymous = true` 或 `MemberId = null`）不計入個人年度收據。  
> **PayPal：** `Amount` = `GrossAmount` - `FeeAmount`（Net Amount 才用於 IRS）。

### GivingReceipt（年度 IRS 奉獻收據）

```
Table: GivingReceipts
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MemberId | int NOT NULL | FK → Members.Id |
| FiscalYear | int NOT NULL | 年份（如 2026）|
| TotalAmount | decimal(18,2) NOT NULL | 該年度奉獻總額 |
| GivingCount | int NOT NULL | 奉獻筆數 |
| PdfBlobPath | varchar(500)? | Azure Blob 路徑（receipts/{year}/{memberId}.pdf）|
| GeneratedAt | timestamp? | PDF 產生時間 |
| SentAt | timestamp? | Email 寄出時間 |
| SentToEmail | varchar(200)? | 寄送 Email 位址 |
| IsVoided | bool NOT NULL DEFAULT false | 作廢（保留 PDF，不再有效）|
| VoidReason | varchar(500)? | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (MemberId, FiscalYear) | 每人每年只一份正式收據 |

### GivingRecurringSchedule（定期奉獻排程 — Phase 4）

```
Table: GivingRecurringSchedules
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MemberId | int NOT NULL | FK → Members.Id |
| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id |
| Amount | decimal(18,2) NOT NULL | |
| Frequency | varchar(20) NOT NULL | 'Weekly' \| 'BiWeekly' \| 'Monthly' |
| StripeSubscriptionId | varchar(200)? | Stripe Subscription ID |
| StartDate | date NOT NULL | |
| EndDate | date? | null = 持續進行 |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

---

## 8. Expense Tracking（支出）

### Form990ExpenseLines（IRS Form 990 Part IX 自然費用科目）

```
Table: Form990ExpenseLines
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| LineCode | varchar(10) NOT NULL UNIQUE | Part IX 行號，如 "7"、"11b"、"16"、"24" |
| Name_en | varchar(200) NOT NULL | 英文科目名稱 |
| Name_zh | varchar(200)? | 中文科目名稱 |
| SortOrder | int NOT NULL | 排序 |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

> **IRS Form 990 Part IX 說明：** Part IX「Statement of Functional Expenses」要求將每筆支出依 IRS 自然費用科目（LineCode）分類，同時按功能性費用分類（Program / Management & General / Fundraising）橫向彙總，生成 Form 990 Schedule Part IX 報表。

### ExpenseCategoryGroup（支出大類）

```
Table: ExpenseCategoryGroups
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| 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 | |
| **Form990LineId** | int? | FK → Form990ExpenseLines.Id，ON DELETE SET NULL。大類層級預設 990 行號；Seed 預設為行 "24"（其他費用）|

**Seed 大類（14 個）：**

| Id | Name_en | Name_zh |
|----|---------|---------|
| 1 | Equipment | 設備 |
| 2 | Consumables | 消耗品 |
| 3 | Food & Beverage | 餐飲 |
| 4 | Training | 培訓 |
| 5 | Materials | 教材 |
| 6 | Facility | 場地 |
| 7 | Printing | 印刷 |
| 8 | Missions | 宣教 |
| 9 | Benevolence | 關懷救助 |
| 10 | Other | 其他 |
| 11 | Personnel | 人事 |
| 12 | Professional Services | 專業服務 |
| 13 | Information Technology | 資訊科技 |
| 14 | Finance & Banking | 財務與銀行 |

### ExpenseSubCategory（支出子項目）

```
Table: ExpenseSubCategories
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| GroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id |
| Name_en | varchar(200) NOT NULL | |
| Name_zh | varchar(200)? | |
| SortOrder | int NOT NULL DEFAULT 0 | |
| IsActive | bool NOT NULL DEFAULT true | |
| **Form990LineId** | int? | FK → Form990ExpenseLines.Id，ON DELETE SET NULL。子項目層級 990 行號（優先於大類值）|

> **有效 990 行號解析順序：** `SubCategory.Form990LineId ?? Group.Form990LineId ?? "24"`（先取子項目的行號；若為 null 則取大類的行號；仍為 null 則視為行 "24" — Other Expenses）。

**Seed 子項目（完整種子）：**

| GroupId | Name_en | Name_zh |
|---------|---------|---------|
| 1 Equipment | Purchase | 購置 |
| 1 Equipment | Rental | 租借 |
| 1 Equipment | Maintenance & Repair | 維修 |
| 2 Consumables | Batteries | 電池 |
| 2 Consumables | Accessories | 配件 |
| 2 Consumables | Cleaning Supplies | 清潔用品 |
| 2 Consumables | Office Supplies | 文具 |
| 3 Food & Beverage | Catering | 出餐費用 |
| 3 Food & Beverage | Food Ingredients | 食材採購 |
| 3 Food & Beverage | Utensils | 器具 |
| 3 Food & Beverage | Disposable Tableware | 一次性餐具 |
| 4 Training | Course Fees | 課程費用 |
| 4 Training | Books | 書籍 |
| 4 Training | Conference | 研討會 |
| 4 Training | Travel | 差旅 |
| 5 Materials | Curriculum Printing | 教材印刷 |
| 5 Materials | Craft Supplies | 手工材料 |
| 5 Materials | Copyright & Licensing | 版權購買 |
| 6 Facility | Rent | 場地租金 |
| 6 Facility | Utilities | 水電 |
| 6 Facility | Property Insurance | 財產保險 |
| 6 Facility | Decoration | 裝飾 |
| 6 Facility | Repairs & Maintenance | 修繕維護 |
| 7 Printing | Bulletins | 週報 |
| 7 Printing | Order of Service | 程序單 |
| 7 Printing | Posters | 海報 |
| 7 Printing | Advertising & Promotion | 廣告與推廣 |
| 8 Missions | Offering Transfer | 奉獻轉帳 |
| 8 Missions | Missionary Support | 宣教士支援 |
| 8 Missions | Foreign Missions Support | 海外宣教支援 |
| 8 Missions | Travel | 差旅 |
| 9 Benevolence | Emergency Aid | 急難救助 |
| 9 Benevolence | Condolence Gifts | 慰問禮品 |
| 9 Benevolence | Visit Expenses | 探訪費用 |
| 10 Other | Miscellaneous | 雜支 |
| 10 Other | Gifts | 禮品 |
| 11 Personnel | Salary & Wages | 薪資 |
| 11 Personnel | Officer / Key Employee Compensation | 主任/關鍵員工薪酬 |
| 11 Personnel | Payroll Taxes | 薪資稅費 |
| 11 Personnel | Employee Benefits | 員工福利 |
| 11 Personnel | Retirement / Pension | 退休/養老金 |
| 11 Personnel | Workers Compensation | 勞工保險 |
| 11 Personnel | Honorarium | 酬庸 |
| 11 Personnel | Staff Training | 同工進修 |
| 11 Personnel | Contract Labor | 外包勞務 |

> **備注：** `Facility > 財產保險` 指建築物／場地責任險；員工健保、團體保險等歸 `Personnel > 員工福利`。同工代墊報銷依**實際購買物**選大類，不歸人事。  
> **修繕歸類：** 建物日常修繕（水電、油漆等）歸 `Facility > 修繕維護`（990 第16行 Occupancy）；**設備**維修歸 `Equipment > Maintenance & Repair`（990 第13行）。重大資本改良（整修屋頂、大規模裝修）應資本化、走折舊（第22行，目前未實作）。  
> **子項目更名說明：** `Food & Beverage > Consumables`（消耗品）更名為 `Disposable Tableware`（一次性餐具）以消除與大類同名的歧義；`Materials > Printing`（印刷費用）更名為 `Curriculum Printing`（教材印刷）以與 Printing 大類區分。

### Expense（支出記錄）

```
Table: Expenses
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MinistryId | int NOT NULL | FK → Ministries.Id |
| CategoryGroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id |
| SubCategoryId | int NOT NULL | FK → ExpenseSubCategories.Id |
| Type | varchar(30) NOT NULL | 'VendorPayment' \| 'StaffReimbursement' |
| Status | varchar(30) NOT NULL DEFAULT 'Draft' | 見下方說明 |
| Amount | decimal(18,2) NOT NULL | |
| Description | varchar(500) NOT NULL | 費用說明 |
| VendorName | varchar(200)? | 廠商名稱（VendorPayment 用）|
| CheckNumber | varchar(50)? | 付款支票號碼 |
| ExpenseDate | date NOT NULL | 費用日期 |
| ReceiptBlobPath | varchar(500)? | 收據照片 Azure Blob 路徑 |
| 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 |
| **FunctionalClass** | varchar(20)? | IRS Form 990 功能性費用分類個別覆寫：'Program' \| 'ManagementGeneral' \| 'Fundraising'；null = 繼承 Ministry.DefaultFunctionalClass |
| IsDeleted | bool NOT NULL DEFAULT false | |
| DeletedAt | timestamp? | |
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

> **有效功能性費用分類解析順序：** `Expense.FunctionalClass ?? Ministry.DefaultFunctionalClass ?? 'Program'`（先取費用記錄的個別覆寫值；若為 null 則取所屬事工的預設值；仍為 null 則視為 'Program'）。

**Status 工作流程：**

```
VendorPayment:
  財務直接建立 → Status = 'Paid'（無需審核）

StaffReimbursement:
  同工提交 → 'Draft'
  提交審核 → 'PendingApproval'
  財務審核通過 → 'Approved'
  標記已還款 → 'Paid'
  財務拒絕 → 'Rejected'
```

### MonthlyStatement（月底對帳表）

```
Table: MonthlyStatements
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| 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 | 系統計算：本月奉獻合計 |
| TotalOtherIncome | decimal(18,2) NOT NULL DEFAULT 0 | 其他收入（手輸）|
| TotalExpenses | decimal(18,2) NOT NULL | 系統計算：本月已付支出合計 |
| CalculatedClosingBalance | decimal(18,2) NOT NULL | = OpeningBalance + TotalGiving + TotalOtherIncome − TotalExpenses |
| BankStatementBalance | decimal(18,2) NOT NULL | 銀行對帳單期末餘額（手輸）|
| Difference | decimal(18,2) NOT NULL | = CalculatedClosingBalance − BankStatementBalance（目標 = 0）|
| Notes | text? | |
| IsFinalized | bool NOT NULL DEFAULT false | 定稿後鎖定，不允許修改 |
| FinalizedAt | timestamp? | |
| FinalizedBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 |

---

## 9. Prayer Requests（代禱）

### PrayerRequest（代禱事項）

```
Table: PrayerRequests
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| RequestedByMemberId | int NOT NULL | FK → Members.Id（提交人）|
| Title | varchar(300) NOT NULL | |
| Content | text NOT NULL | |
| Visibility | varchar(20) NOT NULL | 'Private' \| 'CellGroup' \| 'AllLeaders' \| 'Public' |
| IsAnswered | bool NOT NULL DEFAULT false | 已應允 |
| AnsweredAt | timestamp? | |
| ExpiresAt | date? | 可選到期日（過期不顯示）|
| IsDeleted | bool NOT NULL DEFAULT false | |
| DeletedAt | timestamp? | |
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| UpdatedAt | timestamp NOT NULL | |

**Visibility 規則：**

| Visibility | 可見對象 |
|------------|---------|
| Private | 僅本人 + pastor + super_admin |
| CellGroup | 本人所在小組成員 + cell_leader + district_leader + pastor |
| AllLeaders | ministry_leader / district_leader / coworker_chair / board_member / pastor 及以上 |
| Public | 全體教友（登入後可見）|

### PrayerFollow（代禱跟進記錄）

```
Table: PrayerFollows
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| PrayerRequestId | int NOT NULL | FK → PrayerRequests.Id |
| FollowedByUserId | varchar(450) NOT NULL | FK → AspNetUsers.Id（牧者/組長）|
| Note | text? | 跟進備注 |
| CreatedAt | timestamp NOT NULL | |

---

## 10. Audit Log

### AuditLog（稽核記錄 — 不可修改/刪除）

```
Table: AuditLogs
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | bigint PK | 使用 bigint（記錄量大）|
| UserId | varchar(450)? | FK → AspNetUsers.Id；null = 系統自動操作 |
| Action | varchar(50) NOT NULL | 'Create' \| 'Update' \| 'Delete' \| 'Login' \| 'Logout' \| 'Export' \| 'ViewSensitive' |
| EntityType | varchar(100) NOT NULL | 實體名稱（如 'Member', 'Giving', 'Expense'）|
| EntityId | varchar(100)? | 受影響實體的主鍵值 |
| OldValues | jsonb? | 修改前的 JSON 快照 |
| NewValues | jsonb? | 修改後的 JSON 快照 |
| IpAddress | varchar(45)? | IPv4 或 IPv6 |
| UserAgent | varchar(500)? | 瀏覽器/App 資訊 |
| CreatedAt | timestamp NOT NULL DEFAULT now() | |

> **重要：** AuditLog 永遠只有 INSERT，永遠不 UPDATE 或 DELETE。

---

## 11. Notifications

### NotificationLog（通知發送記錄）

```
Table: NotificationLogs
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| UserId | varchar(450)? | FK → AspNetUsers.Id |
| MemberId | int? | FK → Members.Id |
| Channel | varchar(20) NOT NULL | 'Email' \| 'Push' \| 'SMS' \| 'InApp' |
| TemplateKey | varchar(100) NOT NULL | 通知模板 Key（如 'giving.receipt.sent'）|
| Subject | varchar(300)? | Email 主旨 |
| SentToAddress | varchar(200)? | Email / 手機號 / FCM Token |
| Status | varchar(20) NOT NULL | 'Sent' \| 'Failed' \| 'Queued' |
| ErrorMessage | varchar(500)? | 失敗原因 |
| RelatedEntityType | varchar(100)? | 關聯實體類型 |
| RelatedEntityId | varchar(100)? | 關聯實體 ID |
| CreatedAt | timestamp NOT NULL DEFAULT now() | |

---

## 12. Service Roster（服事表 — Phase 2）

### ServiceSlot（服事項目定義）

```
Table: ServiceSlots
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MinistryId | int NOT NULL | FK → Ministries.Id |
| Name_en | varchar(200) NOT NULL | e.g., 'Worship Leader' |
| Name_zh | varchar(200)? | e.g., '敬拜帶領' |
| RequiredCount | int NOT NULL DEFAULT 1 | 每次需要幾人 |
| IsActive | bool NOT NULL DEFAULT true | |
| SortOrder | int NOT NULL DEFAULT 0 | |

**Seed 服事項目（16 個主日服事槽，參見 PLANNING.md §3.4）：**

| MinistryId | Name_en | Name_zh |
|------------|---------|---------|
| 2 Preaching | Preacher | 講員 |
| 3 Emcee | Emcee | 司會 |
| 4 Worship | Worship Leader | 敬拜帶領 |
| 4 Worship | Keyboard / Piano | 鍵盤/鋼琴 |
| 4 Worship | Guitar | 吉他 |
| 4 Worship | Bass | 貝斯 |
| 4 Worship | Drums | 爵士鼓 |
| 4 Worship | Vocalist | 詩班 |
| 5 PPT/Media | PPT Operator | PPT 操作 |
| 5 PPT/Media | Livestream | 直播 |
| 6 Sound | Sound Engineer | 音控 |
| 7 Facility | Setup Lead | 場地佈置組長 |
| 7 Facility | Setup Team | 場地佈置組員 |
| 8 Hospitality | Greeter | 招待接待 |
| 9 Children | Children's Teacher | 兒童老師 |
| 10 Catering | Agape Meal Coord. | 愛宴負責人 |

### ServiceAssignment（服事排班記錄）

```
Table: ServiceAssignments
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| ServiceSlotId | int NOT NULL | FK → ServiceSlots.Id |
| MemberId | int NOT NULL | FK → Members.Id |
| ServiceDate | date NOT NULL | 服事日期（通常為主日）|
| Status | varchar(20) NOT NULL DEFAULT 'Scheduled' | 'Scheduled' \| 'Confirmed' \| 'Absent' \| 'Replaced' |
| ReplacedByMemberId | int? | FK → Members.Id（替補人選）|
| Notes | varchar(200)? | |
| ReminderSentAt | timestamp? | 提醒通知發送時間 |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

---

## 13. Sunday Attendance（主日出席 — Phase 2）

```
Table: SundayAttendances
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| AttendanceDate | date NOT NULL UNIQUE | 主日日期 |
| AdultCount | int NOT NULL DEFAULT 0 | 大人 |
| TeenagerCount | int NOT NULL DEFAULT 0 | 青少年 |
| ChildrenCount | int NOT NULL DEFAULT 0 | 兒童 |
| TotalCount | int NOT NULL | 計算欄位（= Adult + Teenager + Children）|
| Notes | varchar(500)? | |
| RecordedByUserId | varchar(450) NOT NULL | FK → AspNetUsers.Id（行政秘書）|
| CreatedAt | timestamp NOT NULL | |
| UpdatedAt | timestamp NOT NULL | |

---

## 14. Cell Groups（小組 — Phase 2）

### CellGroup（小組）

```
Table: CellGroups
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Name_en | varchar(200) NOT NULL | |
| Name_zh | varchar(200)? | |
| ParentGroupId | int? | FK → CellGroups.Id（巢狀小組）|
| LeaderMemberId | int? | FK → Members.Id（組長）|
| CoLeaderMemberId | int? | FK → Members.Id（副組長）|
| MeetingDay | varchar(20)? | 'Monday' \| 'Tuesday' \| ... |
| MeetingTime | time? | |
| MeetingLocation | varchar(200)? | |
| Description_en | text? | |
| Description_zh | text? | |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### CellGroupMembership（小組成員 — M:N）

```
Table: CellGroupMemberships
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| CellGroupId | int NOT NULL | FK → CellGroups.Id |
| MemberId | int NOT NULL | FK → Members.Id |
| JoinedAt | date? | |
| LeftAt | date? | null = 仍在小組中 |
| IsActive | bool NOT NULL DEFAULT true | |
| **UNIQUE** | (CellGroupId, MemberId) WHERE IsActive = true | |

### CellGroupMeeting（小組聚會記錄）

```
Table: CellGroupMeetings
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| CellGroupId | int NOT NULL | FK → CellGroups.Id |
| MeetingDate | date NOT NULL | |
| Topic | varchar(300)? | 聚會主題 |
| Notes | text? | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |

### CellGroupAttendance（小組出席 — 個人層級）

```
Table: CellGroupAttendances
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MeetingId | int NOT NULL | FK → CellGroupMeetings.Id |
| MemberId | int NOT NULL | FK → Members.Id |
| IsPresent | bool NOT NULL DEFAULT true | 出席 / 缺席 |
| Notes | varchar(200)? | |
| **UNIQUE** | (MeetingId, MemberId) | |

---

## 15. Ministry Budget（事工預算 — Phase 3）

```
Table: MinistryBudgets
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MinistryId | int NOT NULL | FK → Ministries.Id |
| CategoryGroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id |
| SubCategoryId | int? | FK → ExpenseSubCategories.Id；null = 整個大類的預算 |
| FiscalYear | int NOT NULL | 財政年度（如 2027）|
| BudgetAmount | decimal(18,2) NOT NULL | |
| Notes | varchar(500)? | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (MinistryId, CategoryGroupId, SubCategoryId, FiscalYear) | |

> **備注：** 此表 Phase 3 才建立，但 `ExpenseCategoryGroups`、`ExpenseSubCategories`、`Ministries` 表已在 Phase 1 建好，無需 Schema 改動。

---

## 16. Seed Data

以下資料在 `DbInitializer`（或 `HasData` Migration）中植入：

### Roles（角色）
```
super_admin, pastor, board_member, coworker_chair, ministry_leader, district_leader, cell_leader, coworker, finance, secretary, worship_leader, member, visitor
```

### Ministries（12 個事工部門）
```
1. Administration / 行政           DefaultFunctionalClass = 'ManagementGeneral'
2. Preaching / 講道                DefaultFunctionalClass = 'Program'
3. Emcee / 司會                    DefaultFunctionalClass = 'Program'
4. Worship / 敬拜                  DefaultFunctionalClass = 'Program'
5. PPT/Media / PPT/影音            DefaultFunctionalClass = 'Program'
6. Sound / 音控                    DefaultFunctionalClass = 'Program'
7. Facility / 場地組               DefaultFunctionalClass = 'Program'
8. Hospitality / 招待              DefaultFunctionalClass = 'Program'
9. Children / 兒牧                 DefaultFunctionalClass = 'Program'
10. Catering / 餐飲                DefaultFunctionalClass = 'Program'
11. Cell Groups / 小組牧養         DefaultFunctionalClass = 'Program'
12. Special Events / 特別活動      DefaultFunctionalClass = 'Program'
```

### GivingCategories（奉獻類型）
```
1. Tithe / 什一奉獻
2. General Offering / 一般奉獻
3. Special Offering / 特別奉獻
4. Building Fund / 建堂基金
5. Mission / 宣教奉獻
```

### ExpenseCategoryGroups（14 個大類）
```
見 §8 Seed 大類列表（含新增 Professional Services、Information Technology、Finance & Banking）
```

### Form990 權限模組
```
Form990Report — 唯讀報表權限，授予角色：finance、pastor、board_member
```

### CmsPages（靜態頁面 Slug）
```
about, vision, service-times, contact
```

---

## 17. Indexes

```sql
-- Members
CREATE INDEX idx_members_status ON "Members" ("Status") WHERE "IsDeleted" = false;
CREATE INDEX idx_members_family_unit ON "Members" ("FamilyUnitId");
CREATE INDEX idx_members_email ON "Members" ("Email") WHERE "Email" IS NOT NULL;

-- Giving
CREATE INDEX idx_givings_member_date ON "Givings" ("MemberId", "GivingDate");
CREATE INDEX idx_givings_session ON "Givings" ("OfferingSessionId") WHERE "OfferingSessionId" IS NOT NULL;
CREATE INDEX idx_givings_date ON "Givings" ("GivingDate");

-- Expense
CREATE INDEX idx_expenses_ministry ON "Expenses" ("MinistryId");
CREATE INDEX idx_expenses_status ON "Expenses" ("Status") WHERE "IsDeleted" = false;
CREATE INDEX idx_expenses_date ON "Expenses" ("ExpenseDate");

-- AuditLog
CREATE INDEX idx_auditlog_user ON "AuditLogs" ("UserId");
CREATE INDEX idx_auditlog_entity ON "AuditLogs" ("EntityType", "EntityId");
CREATE INDEX idx_auditlog_created ON "AuditLogs" ("CreatedAt");

-- PrayerRequest
CREATE INDEX idx_prayer_visibility ON "PrayerRequests" ("Visibility") WHERE "IsDeleted" = false;
CREATE INDEX idx_prayer_member ON "PrayerRequests" ("RequestedByMemberId");

-- Phase 2: ServiceAssignment
CREATE INDEX idx_service_assign_date ON "ServiceAssignments" ("ServiceDate");
CREATE INDEX idx_service_assign_member ON "ServiceAssignments" ("MemberId");

-- Phase 2: SundayAttendance
-- UNIQUE constraint on AttendanceDate already creates an index
```

---

## 18. EF Core 設定摘要

### DbContext 範例結構

```csharp
public class RolacDbContext : IdentityDbContext<AppUser, AppRole, string>
{
    // Phase 1
    public DbSet<Member> Members => Set<Member>();
    public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
    public DbSet<MemberMinistry> MemberMinistries => Set<MemberMinistry>();
    public DbSet<MemberTag> MemberTags => Set<MemberTag>();
    public DbSet<Ministry> Ministries => Set<Ministry>();
    public DbSet<UserMinistry> UserMinistries => Set<UserMinistry>();
    public DbSet<UserDevice> UserDevices => Set<UserDevice>();

    public DbSet<Announcement> Announcements => Set<Announcement>();
    public DbSet<SermonVideo> SermonVideos => Set<SermonVideo>();
    public DbSet<CmsPage> CmsPages => Set<CmsPage>();
    public DbSet<ContactInquiry> ContactInquiries => Set<ContactInquiry>();

    public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
    public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
    public DbSet<Giving> Givings => Set<Giving>();
    public DbSet<GivingReceipt> GivingReceipts => Set<GivingReceipt>();
    public DbSet<GivingRecurringSchedule> GivingRecurringSchedules => Set<GivingRecurringSchedule>();

    public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
    public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
    public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
    public DbSet<Expense> Expenses => Set<Expense>();
    public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();

    public DbSet<PrayerRequest> PrayerRequests => Set<PrayerRequest>();
    public DbSet<PrayerFollow> PrayerFollows => Set<PrayerFollow>();

    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
    public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();

    // Phase 2 (schema defined now, implemented later)
    public DbSet<ServiceSlot> ServiceSlots => Set<ServiceSlot>();
    public DbSet<ServiceAssignment> ServiceAssignments => Set<ServiceAssignment>();
    public DbSet<SundayAttendance> SundayAttendances => Set<SundayAttendance>();
    public DbSet<CellGroup> CellGroups => Set<CellGroup>();
    public DbSet<CellGroupMembership> CellGroupMemberships => Set<CellGroupMembership>();
    public DbSet<CellGroupMeeting> CellGroupMeetings => Set<CellGroupMeeting>();
    public DbSet<CellGroupAttendance> CellGroupAttendances => Set<CellGroupAttendance>();

    // Phase 3
    public DbSet<MinistryBudget> MinistryBudgets => Set<MinistryBudget>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // Apply all entity configurations from assembly
        builder.ApplyConfigurationsFromAssembly(typeof(RolacDbContext).Assembly);

        // Global soft-delete filter
        builder.Entity<Member>().HasQueryFilter(m => !m.IsDeleted);
        builder.Entity<Expense>().HasQueryFilter(e => !e.IsDeleted);
        builder.Entity<PrayerRequest>().HasQueryFilter(p => !p.IsDeleted);

        // Decimal precision
        foreach (var property in builder.Model.GetEntityTypes()
            .SelectMany(t => t.GetProperties())
            .Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
        {
            property.SetColumnType("decimal(18,2)");
        }
    }
}
```

### 重要 EF Core 慣例

```csharp
// 軟刪除攔截 (SaveChangesInterceptor)
// 在 SaveChanges 前，如果 EntityState = Deleted 且實體繼承 SoftDeleteEntity，
// 改成 Modified 並設定 IsDeleted = true, DeletedAt = now(), DeletedBy = currentUser

// Audit 自動填充 (SaveChangesInterceptor)
// 在 SaveChanges 前，自動填充 CreatedAt/CreatedBy（新增）和 UpdatedAt/UpdatedBy（修改）
// CurrentUser 透過 IHttpContextAccessor 取得

// AuditLog 攔截 (SaveChangesInterceptor)
// 記錄所有 Create/Update/Delete 動作到 AuditLogs 表
// OldValues / NewValues 使用 JsonSerializer.Serialize(entry.OriginalValues.ToObject())
```

---

*文件由 ROLAC 開發團隊維護。如需更新 Schema，必須同步更新此文件和 EF Core Migration。*
