Files
ROLAC/docs/DB_SCHEMA.md
T
Chris Chen 9b28fbcfb6 Initial commit: monorepo scaffold for ROLAC
- Add .gitignore covering C#/.NET and Angular/Node
- Add placeholder structure for API (C#) and APP (Angular)
- Add project docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:54:10 -07:00

1098 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 訪客 | 僅限公開頁面 |
### UserMinistryMinistry 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) | 一個用戶不重複指派同一事工 |
### UserDeviceFCM 推播 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 可有多個 MembersOne-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 | |
---
## 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.Idnull = 匿名 |
| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id |
| OfferingSessionId | int? | FK → OfferingSessions.Idnull = 非批次單筆 |
| 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 IDPhase 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(支出)
### 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 | |
**Seed 大類(10 個):**
| 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 | 其他 |
### 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 | |
**Seed 子項目(部分範例):**
| GroupId | Name_en | Name_zh |
|---------|---------|---------|
| 1 Equipment | Audio/Visual Equipment | 影音設備 |
| 1 Equipment | Computer & Peripherals | 電腦周邊 |
| 1 Equipment | Musical Instruments | 樂器 |
| 2 Consumables | Office Supplies | 辦公用品 |
| 2 Consumables | Cleaning Supplies | 清潔用品 |
| 3 Food & Beverage | Sunday Agape Meal | 愛宴餐費 |
| 3 Food & Beverage | Fellowship Snacks | 交誼茶點 |
| 4 Training | Conference Registration | 會議報名費 |
| 4 Training | Books & Resources | 書籍教材 |
| 5 Materials | Children's Curriculum | 兒童教材 |
| 5 Materials | Bibles & Hymnals | 聖經/詩本 |
| 6 Facility | Rent | 場地租金 |
| 6 Facility | Utilities | 水電費 |
| 7 Printing | Bulletins | 週報印刷 |
| 8 Missions | Missionary Support | 宣教士支持 |
### 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 |
| 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 |
**Status 工作流程:**
```
VendorPayment:
財務直接建立 → Status = 'Paid'(無需審核)
StaffReimbursement:
同工提交 → 'Draft'
提交審核 → 'PendingApproval'
財務審核通過 → 'Approved'
標記已還款 → 'Paid'
財務拒絕 → 'Rejected'
```
### MonthlyStatement(月底對帳表)
```
Table: MonthlyStatements
```
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Year | int NOT NULL | |
| Month | int NOT NULL | 112 |
| 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.Idnull = 系統自動操作 |
| 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.Idnull = 整個大類的預算 |
| 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
```
### Ministries10 個事工部門)
```
1. Administration / 行政
2. Preaching / 講道
3. Emcee / 司會
4. Worship / 敬拜
5. PPT/Media / PPT/影音
6. Sound / 音控
7. Facility / 場地組
8. Hospitality / 招待
9. Children / 兒牧
10. Catering / 餐飲
```
### GivingCategories(奉獻類型)
```
1. Tithe / 什一奉獻
2. General Offering / 一般奉獻
3. Special Offering / 特別奉獻
4. Building Fund / 建堂基金
5. Mission / 宣教奉獻
```
### ExpenseCategoryGroups10 個大類)
```
見 §8 Seed 大類列表
```
### 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<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。*