# 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 { // Phase 1 public DbSet Members => Set(); public DbSet FamilyUnits => Set(); public DbSet MemberMinistries => Set(); public DbSet MemberTags => Set(); public DbSet Ministries => Set(); public DbSet UserMinistries => Set(); public DbSet UserDevices => Set(); public DbSet Announcements => Set(); public DbSet SermonVideos => Set(); public DbSet CmsPages => Set(); public DbSet ContactInquiries => Set(); public DbSet GivingCategories => Set(); public DbSet OfferingSessions => Set(); public DbSet Givings => Set(); public DbSet GivingReceipts => Set(); public DbSet GivingRecurringSchedules => Set(); public DbSet Form990ExpenseLines => Set(); public DbSet ExpenseCategoryGroups => Set(); public DbSet ExpenseSubCategories => Set(); public DbSet Expenses => Set(); public DbSet MonthlyStatements => Set(); public DbSet PrayerRequests => Set(); public DbSet PrayerFollows => Set(); public DbSet AuditLogs => Set(); public DbSet NotificationLogs => Set(); // Phase 2 (schema defined now, implemented later) public DbSet ServiceSlots => Set(); public DbSet ServiceAssignments => Set(); public DbSet SundayAttendances => Set(); public DbSet CellGroups => Set(); public DbSet CellGroupMemberships => Set(); public DbSet CellGroupMeetings => Set(); public DbSet CellGroupAttendances => Set(); // Phase 3 public DbSet MinistryBudgets => Set(); 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().HasQueryFilter(m => !m.IsDeleted); builder.Entity().HasQueryFilter(e => !e.IsDeleted); builder.Entity().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。*