diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 19df871..4ebfcb4 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -197,6 +197,10 @@ }, "active": "849c5ff8936a2b67", "lastOpenFiles": [ + "prop-acc/concepts/billing/bill-vs-collection-order.md", + "prop-acc/concepts/billing/bill-types-and-sources.md", + "prop-acc/concepts/billing/bill-six-state-machine.md", + "prop-acc/concepts/billing", "prop-acc/scenarios/meter/audit-meters-needing-reading.md", "prop-acc/scenarios/meter/exception-readings-locked-after-bill.md", "prop-acc/scenarios/meter/exception-high-consumption.md", @@ -222,16 +226,12 @@ "prop-acc/concepts/meter", "prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md", "prop-acc/scenarios/prepaid/exception-refund-on-frozen.md", - "prop-acc/scenarios/prepaid/exception-cross-community-consume.md", - "prop-acc/scenarios/prepaid/close-with-zero-balance-decision.md", - "prop-acc/scenarios/prepaid/close-resident-moveout.md", "prop-acc/scenarios/prepaid", "prop-acc/concepts/prepaid", "prop-acc/scenarios/deposit", "prop-acc/concepts/deposit", "prop-acc/scenarios/adhoc", "prop-acc/concepts/adhoc", - "resident-portal/scenarios", - "resident-portal/reference" + "resident-portal/scenarios" ] } \ No newline at end of file diff --git a/prop-acc/concepts/billing/bill-six-state-machine.md b/prop-acc/concepts/billing/bill-six-state-machine.md new file mode 100644 index 0000000..7ceb3f3 --- /dev/null +++ b/prop-acc/concepts/billing/bill-six-state-machine.md @@ -0,0 +1,222 @@ +--- +title: prop-acc · billing · 账单六状态机 +aliases: + - 账单六状态机 + - Bill 状态机 + - BillStatus + - 6 个账单状态 +tags: + - 概念 + - prop-acc + - 账单 + - 状态机 + - 核心概念 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 账单六状态机 + +`Bill` 是 prop-acc 状态机**最复杂**的子模块,有 **6 个状态**:**Unpaid / Partial / Paid / Suspended / Processing / Void**。比 deposit / prepaid / meter 的 3-4 态都细,反映账单从生成到关闭的全生命周期 + 异常处置路径。 + +## 6 状态速查 + +| 状态 | 中文 | 何时进入 | 能做什么 | +|---|---|---|---| +| `Unpaid` | 未付 | 账单刚创建 | 收款 / 拆单 / 挂起 / 删 / 作废 | +| `Partial` | 部分付 | 收到一部分钱(小于账单金额) | 继续收款 / 作废(已收的需退) | +| `Paid` | 已付 | 收齐全部金额 | 作废(走退款) | +| `Suspended` | 挂起 | 业务人员主动暂停(纠纷 / 失联) | 恢复 / 作废 | +| `Processing` | 处理中 | 收款流程中(罕见,如银行扣款排队)| 等回调 | +| `Void` | 作废 | 主动 void | 只读,审计可查 | + +## 状态机图 + +```mermaid +stateDiagram-v2 + [*] --> Unpaid : 创建 + + Unpaid --> Partial : 部分收款 + Unpaid --> Paid : 全额收款 + Unpaid --> Suspended : suspend(纠纷) + Unpaid --> Void : void + Unpaid --> [*] : 物理删除(无付款关联) + + Partial --> Partial : 继续收款(仍未付清) + Partial --> Paid : 收齐 + Partial --> Suspended : suspend + Partial --> Void : void(已收部分需退) + + Paid --> Void : void(已收全额需退) + + Suspended --> Unpaid : resume(纠纷解决) + Suspended --> Partial : resume(回到部分付状态,若之前有付款) + Suspended --> Void : void + + Processing --> Paid : 收款回调成功 + Processing --> Unpaid : 收款回调失败 + + Void --> [*] +``` + +## 守护方法(模型层) + +`Bill` 模型上的辅助方法: + +| 方法 | 返回 true 的状态 | 用途 | +|---|---|---| +| `canBePaid()` | Unpaid / Partial | CollectPaymentAction 准入 | +| `canBeIssued()` | (用于"发出账单"流程,看实现) | — | +| `isVoid()` | Void | UI 灰化判断 | +| `isPaid()` | Paid | 报表 / 收款率统计 | +| `hasAnyPayment()` | 有任何 CollectionOrderBill / prepaid_transaction 关联 | 决定能否物理删 | +| `canBeDeleted()` | Unpaid + 无任何付款关联 | DeleteAction 守护 | +| `canBeVoided()` | 非 Paid 非 Void | VoidBillAction 守护 | + +> [!warning] `canBeVoided` 的微妙之处 +> Paid 状态**也可以走 void**(已付全额作废 → 退款),但 `canBeVoided()` 返回 false。原因: +> - 已付账单的作废需要**走退款流程**,不是单纯改状态 +> - 简单的 `VoidBillAction` 只翻状态 + 留 meta,**不处理钱** +> - 已付的作废应该走专门的"作废 + 退款"组合流程(类似 [[../meter/exception-readings-locked-after-bill|计量账单修正]]) +> +> 即:`canBeVoided()` 检查的是"能否走简单作废",不是"能否所有方式作废"。 + +## 状态转换详解 + +### Unpaid → Partial(部分收款) + +业户付了 ¥300 给 ¥800 的账单: + +``` +- Bill.status: Unpaid → Partial +- 建 CollectionOrderBill(allocated_amount=300) +- 关联 CollectionOrder(actual_amount=+300) +- Bill.paid_amount(若有此字段) = 300 +- 余 500 待付 +``` + +### Partial → Paid(收齐) + +业户再付 ¥500: + +``` +- Bill.status: Partial → Paid +- 建第 2 条 CollectionOrderBill(allocated_amount=500) +- Bill.paid_amount = 800(=账单金额) +- 收齐 +``` + +### Unpaid → Suspended(挂起) + +业户与物业纠纷 / 业户失联: + +``` +- Bill.status: Unpaid → Suspended +- 业务人员走 SuspendBillAction +- 记 suspend_reason + suspended_at(meta) +- 后续 CollectPaymentAction 守护拒绝(canBePaid=false) +``` + +### Suspended → Unpaid(恢复) + +纠纷解决: + +``` +- Bill.status: Suspended → Unpaid(或回到 Partial,若之前有付款) +- 业务人员走 ResumeBillAction +- 记 resume_reason + resumed_at +- 后续 CollectPaymentAction 重新可用 +``` + +### Unpaid → Void(作废) + +误开的账单,业户没付: + +``` +- Bill.status: Unpaid → Void +- VoidBillAction 守护通过(canBeVoided=true) +- 记 voided_reason + voided_at + voided_by +- 业户无影响(没付钱) +``` + +### Paid → Void(已付作废) + +走专门的"作废 + 退款"流程(未来扩展): + +``` +- 当前 VoidBillAction **不允许**(canBeVoided=false for Paid) +- 需要走类似 meter 的修正流程 +- 后续若实施 RefundBillAction:作废 + 退款一次性 +``` + +### Unpaid → 物理删除(误建立刻删) + +``` +- 状态层面消失(从数据库删除) +- 走 DeleteAction +- 守护:canBeDeleted = Unpaid + 无任何付款关联 +- 留 activitylog +``` + +详见 [[delete-vs-void-dual-track]]。 + +## 业务人员视角 + +后台账户列表的状态列对应这 6 个值: + +- 🟢 **Unpaid**:绿色,可操作(收款 / 拆 / 挂起 / 删 / 作废) +- 🟡 **Partial**:黄色,部分付,可继续收款 +- ✅ **Paid**:绿色对号,已完成,无操作 +- 🧊 **Suspended**:灰色雪花,挂起,只能恢复 / 作废 +- ⏳ **Processing**:旋转图标,处理中(等回调) +- ❌ **Void**:红色叉,已作废,只读 + +## 业户视角 + +业户感受到的: + +| 状态 | 业户感知 | +|---|---| +| Unpaid | 收到"账单 ¥800 待付"通知 | +| Partial | 看到"已付 ¥300,还差 ¥500"| +| Paid | 收到"已付清"通知 + 收据 | +| Suspended | (通常通知)"账单挂起,事由 XXX,请联系物业" | +| Processing | 几乎不感知(过渡态)| +| Void | 收到"账单已作废,理由 XXX" | + +## 与其他模块状态机的对比 + +| 模块 | 状态数 | 主要状态 | +|---|---|---| +| deposit | 3 | Active / Frozen / Closed | +| prepaid | 3 | Active / Frozen / Closed | +| meter(`is_active`)| 2 | active / inactive | +| **bill** | **6** | **Unpaid / Partial / Paid / Suspended / Processing / Void** | + +bill 的复杂度反映**收款全流程**:从应收到已收,中间有挂起、部分付、回调中等多种异常路径。 + +## 历史:Policy 修复 + +> [!info] issue.md Q6 第一轮发现的漏洞 +> 原 `BillPolicy` 几乎是空壳(只有 `getPermissionPrefix`),所有授权全靠 `AbstractPolicy` 默认(只查权限,**不查状态**)。 +> +> 修复后补 7 个方法:`update` / `delete` / `deleteAny` / `void` / `collect` / `suspend` / `resume`。每个方法都加了**状态守护**(canBeDeleted / canBeVoided 等)。 +> +> 详见 [[delete-vs-void-dual-track]] 和 [[smart-bulk-delete-design]]。 + +## 相关文档 + +- [[bill-types-and-sources]] +- [[bill-vs-collection-order]] +- [[delete-vs-void-dual-track]] +- [[smart-bulk-delete-design]] +- [[collect-payment-single]] +- [[suspend-bill]] +- [[void-paid-bill]] +- [[../meter/decommission-and-locking]](类似的"状态机+守护"对比) diff --git a/prop-acc/concepts/billing/bill-types-and-sources.md b/prop-acc/concepts/billing/bill-types-and-sources.md new file mode 100644 index 0000000..95b2a33 --- /dev/null +++ b/prop-acc/concepts/billing/bill-types-and-sources.md @@ -0,0 +1,220 @@ +--- +title: prop-acc · billing · 账单类型与来源 +aliases: + - 账单类型 + - 账单来源 + - BillType + - sourceable polymorphism + - 周期账单 vs 计量账单 +tags: + - 概念 + - prop-acc + - 账单 + - 数据模型 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 账单类型与来源 + +`Bill` 可以由**多种业务源**产生:周期任务(月度物业费)、抄表事件(水电费)、手工建单(临时收费)。系统通过 **`sourceable` 多态关联**统一存储,通过 **`BillType` 枚举**区分类型。 + +## 三种主要账单类型 + +### 1. 周期账单(Periodic) + +**业务**:按固定周期(月 / 季 / 年)自动生成的账单。最典型:物业费、停车费、有线电视费。 + +**生成方式**:`GeneratePeriodicBillsAction`(批量,详见 [[periodic-bill-generation]])。 + +**源**:无直接 sourceable(由周期任务批量生成,不指向具体 reading / event)。 + +**特点**: +- 金额固定(由 RatePlan + 房屋面积 / 停车位等参数算) +- 期次明确(`billing_period_start / end`) +- 批量生成(一户一张,或合并) + +### 2. 计量账单(Meter-based) + +**业务**:抄表产生的账单。最典型:水费、电费、燃气费。 + +**生成方式**:`GenerateBillsFromMeterReadingsAction`([[../meter/bill-generation-pipeline]])。 + +**源**:`sourceable_type = MeterReading`,`sourceable_id = reading.id`。 + +**特点**: +- 金额浮动(按用量算) +- 一抄表 → 一 Bill 行 +- 由 [[../meter/multiplier-and-tiered-pricing|阶梯+倍率+min/max]] 三层算法决定金额 + +### 3. 临时账单(Manual / Ad-hoc) + +**业务**:不定期的临时收费,业务人员手工建单。例如:维修费分摊、特别活动费、单次罚款。 + +**生成方式**:`CreateBill` Filament 页面手工建。 + +**源**:无 sourceable(或自定义 sourceable_type)。 + +**特点**: +- 金额手填 +- 单次建单 +- 适合"系统不知道怎么算"的特殊场景 + +## `BillType` 枚举 + +> [!info] 实际 BillType 枚举值看代码 +> 当前 `packages/prop-acc/src/Enums/BillType.php` 的具体枚举值需查代码。常见可能: +> +> - `Periodic`(周期) +> - `Meter`(计量) +> - `OneTime`(一次性 / 临时) +> - `Adjustment`(调整,罕见) +> +> 类型字段用于:报表分类、UI 过滤、业务逻辑分支(例如周期账单的合并策略只对 Periodic 类型生效)。 + +## Sourceable Polymorphism + +Bill 表用 Laravel 多态关联存"源": + +``` +Bill +├── sourceable_type: enum 'App\Models\MeterReading' / 'App\Jobs\GeneratePeriodicBills' / null +├── sourceable_id: 关联记录的 ID +└── (other fields) +``` + +### 类型对照 + +| Bill.sourceable_type | Bill.sourceable_id | 业务来源 | +|---|---|---| +| `MeterReading` | reading.id | 计量账单 | +| `PeriodicBillingTask`(假设) | task.id | 周期账单(若设计为指向任务记录)| +| `null` | null | 手工建单 / 周期任务但不挂任务表 | +| (其他业务可扩展) | (其他)| 未来新业务可加 | + +### 多态的好处 + +| 好处 | 说明 | +|---|---| +| **可追溯** | Bill 上可以查到"这条 Bill 是哪条 reading 生成的" | +| **可反查** | reading 上有 `bill_id`,Bill 上有 `sourceable_*`,双向 | +| **统一接口** | 各业务源都用同样的 Bill 表,不用为每种源单独建表 | +| **扩展友好** | 未来新业务(如"维修工单产生账单")只需:写新 Action + 设 `sourceable_type='WorkOrder'`,不改 Bill 表结构 | + +### 多态的代价 + +- ORM 查询稍复杂(需 morphTo 关系) +- 关联完整性靠应用层(数据库无强制外键到具体表) +- 调试时要看 sourceable_type 才知道是哪种业务 + +## 账单核心字段(简表) + +| 字段 | 含义 | +|---|---| +| `id` | 账单 ID | +| `bill_no` | 账单编号(对外显示,如 `B-202605-501-001`)| +| `community_id` | 所属物业项目 | +| `asset_id` | 关联房屋 | +| `resident_id` | 账单收方业户 | +| `fee_type_id` | 费用类型(物业费 / 水费 / 电费 / ...)| +| **`bill_type`** | **BillType 枚举(Periodic / Meter / ...)** | +| `amount` | 账单金额 | +| `paid_amount` | 已付金额(可能字段名不同)| +| **`status`** | **BillStatus(6 种,见 [[bill-six-state-machine]])** | +| `billing_period_start` / `billing_period_end` | 期次(周期账单用)| +| `due_at` | 到期日 | +| **`sourceable_type`** / **`sourceable_id`** | **多态源关联** | +| `meta` | JSON 扩展(suspend_reason / voided_reason 等审计字段)| + +## 业务人员视角 + +后台账单列表(`BillsTable`)列: + +| 列 | 内容 | +|---|---| +| 账单号 | bill_no | +| 业户 | resident.name | +| 房号 | asset.code | +| 费用类型 | fee_type.name(物业费 / 水费 / ...)| +| **类型(BillType)** | 周期 / 计量 / 临时 | +| 期次 | period_start ~ period_end | +| 金额 | amount | +| 状态 | status(6 状态图标)| +| 操作 | 收款 / 拆 / 挂起 / 作废 / 删 | + +可按 BillType 过滤:看本月所有"周期账单"或所有"计量账单"。 + +## 业户视角 + +业户**通常不关心账单类型**,只看到: + +``` +我的账单 + +5月 物业费 ¥800 ✅ 已付 +5月 水费 ¥54 待付 +5月 电费 ¥168 待付 +5月 燃气 ¥30 待付 +``` + +每条对应一个 Bill,业户不关心是周期还是计量,只关心"该付多少 / 付了没"。 + +## 与 CollectionOrder 的关系 + +Bill 是**应收应付**(应该收的钱),CollectionOrder 是**已收**(实际收到的钱)。两者多对多关联(CollectionOrderBill 中间表)。 + +详见 [[bill-vs-collection-order]]。 + +## 与其他子模块的协作 + +| 业务模块 | 与 billing 的关系 | +|---|---| +| **meter** | 抄表 → 生成计量 Bill(sourceable=MeterReading)| +| **prepaid** | 业户预存款抵 Bill(consume,Bill 状态 Unpaid → Paid)| +| **deposit** | 通常不抵 Bill(押金是代管资金,不主动抵)| +| **adhoc** | AdHocEvent 与 Bill 是兄弟概念(都产 CollectionOrder),但不互相关联 | + +billing 是 prop-acc 的**收款中枢** —— 各种"该收的钱"都先变成 Bill,然后通过 CollectionOrder 完成收款。 + +## 常见问题 + +> [!question] 周期账单和计量账单的 BillType 一定区分吗? +> 业务上**应该区分**。理由: +> +> - 报表分类(物业费收入 vs 水电费收入) +> - 业务逻辑(周期账单可合并,计量账单不合并) +> - 业户感知(账单上区分类型,业户更明白) + +> [!question] sourceable 为 null 的 Bill 怎么追溯来源? +> 看 `created_by`(操作员)+ `created_at`(时间)+ `meta`(备注)。手工建单可在 memo 写清缘由。 + +> [!question] 同一抄表 reading 多次生成 Bill 会怎样? +> 不会发生(理论上): +> - Reading 上有 `bill_id` 字段,有值表示已关联 Bill +> - `GenerateBillsFromMeterReadingsAction` 校验 `bill_id === null`(已有 Bill 跳过) +> +> 但若数据手工乱改 → 可能 Bill 重复 → reading 的 bill_id 会被新建的覆盖,留下"孤儿 Bill"。需运维清理。 + +> [!question] 一个 Bill 关联多个 reading 可以吗? +> 当前 `sourceable_*` 是 1:1(一个 Bill 对应一个 source)。如果业务上一户家有 2 张水表合并出账(罕见):需要扩展(新建中间表 BillReading,或者改 Bill schema 加 child reading 字段)。issue.md 未明确,可作为未来扩展点。 + +> [!question] 临时账单的 sourceable_type 应该是什么? +> `null` 或自定义类型。当前实现倾向 `null`(简单)。如果未来要"临时账单"也有追溯链(例如关联到具体维修工单),可以扩展为 `sourceable_type='WorkOrder'`。 + +## 相关文档 + +- [[bill-six-state-machine]] +- [[bill-vs-collection-order]] +- [[periodic-bill-generation]] +- [[delete-vs-void-dual-track]] +- [[create-periodic-property-fee]] +- [[create-meter-bill-auto]] +- [[create-single-bill-manual]] +- [[../meter/bill-generation-pipeline]] +- [[../prepaid/consume-via-bill-collection-type]] diff --git a/prop-acc/concepts/billing/bill-vs-collection-order.md b/prop-acc/concepts/billing/bill-vs-collection-order.md new file mode 100644 index 0000000..6097ff8 --- /dev/null +++ b/prop-acc/concepts/billing/bill-vs-collection-order.md @@ -0,0 +1,265 @@ +--- +title: prop-acc · billing · Bill 与 CollectionOrder 的关系 +aliases: + - Bill vs CollectionOrder + - CollectionOrderBill + - 应收 vs 已收 + - 账单与收款单 +tags: + - 概念 + - prop-acc + - 账单 + - 核心概念 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# Bill 与 CollectionOrder 的关系 + +`Bill`(应收 / 应付)和 `CollectionOrder`(已收)是 prop-acc 的**两个核心对象**: + +- **Bill** = "这是业户该付的钱"(应收账款) +- **CollectionOrder** = "业户实际付了多少 / 怎么付的"(已收记录) + +两者通过 **`CollectionOrderBill` 中间表多对多关联** —— 一张 Bill 可能由多笔 CollectionOrder 凑齐(部分付);一笔 CollectionOrder 可能付多张 Bill(批量收款)。 + +## 一对多 vs 多对多 + +### 错例:一对一 + +``` +Bill ─── CollectionOrder +``` + +业务上**不够用**: +- 业户付一半 → 怎么记? +- 业户一次付 3 张账单 → 怎么记? +- 一张大账单分两次付 → 怎么记? + +### 正例:多对多(本设计) + +``` +Bill ─── CollectionOrderBill ─── CollectionOrder + M ───────── N ───────── M ───────── N +``` + +- 1 个 Bill 可对应多个 CollectionOrderBill(一张账单分两次付) +- 1 个 CollectionOrder 可对应多个 CollectionOrderBill(一次收款付多张账单) +- `CollectionOrderBill` 中间表存**分配金额**(`allocated_amount`) + +## `CollectionOrderBill` 中间表 + +| 字段 | 含义 | +|---|---| +| `id` | 主键 | +| `collection_order_id` | 关联收款单 | +| `bill_id` | 关联账单 | +| **`allocated_amount`** | **本次分配给该账单的金额**(可以小于账单金额 = 部分付)| +| `created_at` | 关联时间 | + +> [!info] allocated_amount 是关键 +> 这个字段决定"这笔收款付了这张账单多少",而不是"账单全付了"。例如: +> +> - 业户付 ¥1,000 想分摊到 3 张账单(¥300 + ¥400 + ¥300) +> - 建 1 个 CollectionOrder(¥1,000) +> - 建 3 个 CollectionOrderBill(分别 allocated_amount 300/400/300,关联各自的 Bill) +> - 3 张 Bill 的 paid_amount 累加各自的 allocated_amount + +## 完整模型关系图 + +```mermaid +erDiagram + Bill ||--o{ CollectionOrderBill : "1:N" + CollectionOrderBill }o--|| CollectionOrder : "N:1" + + Bill { + id PK + bill_no + amount + paid_amount + status + } + + CollectionOrderBill { + id PK + bill_id FK + collection_order_id FK + allocated_amount + } + + CollectionOrder { + id PK + collection_type + actual_amount + payment_channel_id + status + } +``` + +## 业务场景对照 + +### 场景 1:简单单笔收款 + +业户付 ¥800 物业费,一笔现金: + +``` +Bill #B-001 (amount=800, status=Unpaid) + │ + └── CollectionOrderBill #1 (allocated_amount=800) + │ + └── CollectionOrder #CO-001 (type=Bill, actual_amount=+800, payment=现金) + │ + └── Receipt #R-001 ("物业费 ¥800") +``` + +Bill 状态翻 Paid,完成。 + +### 场景 2:同业户批量收款(本月所有账单一起付) + +业户付 ¥1,082 = 物业费 800 + 水费 54 + 电费 168 + 燃气 60: + +``` +Bill #B-001 物业费 (amount=800) ──┐ +Bill #B-002 水费 (amount=54) ──┤ +Bill #B-003 电费 (amount=168) ──┤ +Bill #B-004 燃气 (amount=60) ──┤ + │ + 4 个 CollectionOrderBill ──┘ + │ + └── 1 个 CollectionOrder (actual_amount=+1082, payment=微信) + │ + └── 1 个 Receipt(可能列出 4 个明细) +``` + +走 `BatchCollectPaymentAction`,详见 [[collect-payment-batch]]。 + +### 场景 3:部分付(Partial) + +业户付 ¥300 给 ¥800 物业费(欠 ¥500): + +``` +Bill #B-001 物业费 (amount=800, status=Partial) + │ + └── CollectionOrderBill #1 (allocated_amount=300) + │ + └── CollectionOrder #CO-001 (actual_amount=+300, payment=现金) +``` + +Bill 状态:**Partial**(详见 [[bill-six-state-machine]])。后续业户补付 ¥500: + +``` +Bill #B-001 (amount=800, status=Paid) ← 收齐变 Paid + │ + ├── CollectionOrderBill #1 (allocated_amount=300, 关联 CO-001) + └── CollectionOrderBill #2 (allocated_amount=500, 关联 CO-002) + │ + └── CollectionOrder #CO-002 (+500) +``` + +### 场景 4:预存款抵扣 + +业户预存款余额 ¥5,000,自动抵 ¥800 物业费: + +``` +Bill #B-001 (amount=800, status=Unpaid → Paid) + │ + └── CollectionOrderBill #1 (allocated_amount=800) + │ + └── CollectionOrder #CO-001 (type=Bill, actual_amount=+800, + meta.fund_source=prepaid) + │ + └── PrepaidTransaction (type=consume, amount=800) +``` + +详见 [[../prepaid/consume-via-bill-collection-type]]。**关键**:CollectionOrder.type 仍是 `Bill`(账单视角),fund_source 标 prepaid。 + +## CollectionOrderBill 是只读管理 + +`CollectionAllocationsRelationManager`(Bill 详情页的子表)**完全只读**(无任何 Action,不可改 / 不可删 / 不可加)。理由: + +- 分配是收款 Action 内事务一次性写入,业务上不需要"事后调整" +- 改了会导致 Bill.paid_amount 与 allocations 累加值不一致 → 数据脱节 +- 误删等于"业户付的钱凭空消失" → 灾难 + +issue.md Q6 明确:**"`CollectionAllocationsRelationManager` 完美保持不动:作为只读管理器的最佳示范"**。 + +## 业务人员视角 + +后台 Bill 详情(`ViewBill`)的子表"收款分配": + +| 列 | 内容 | +|---|---| +| 收款单号 | CO-XXX | +| 分配金额 | allocated_amount | +| 付款方式 | CO 的 payment_channel | +| 付款时间 | CO 的 created_at | + +业务人员看明白:"这张账单的 ¥800 是 5/15 微信付的"。 + +## 业户视角 + +业户**通常感受不到**这两个对象的复杂关系。看到的是: + +- 账单状态(Unpaid / Partial / Paid / ...) +- 已付金额 / 未付金额 +- 收款明细(几次付、什么时候付、用什么方式付) + +整体感知:**"我付了 ¥800 物业费"**,而不是"我建了一个 CollectionOrder 关联到 Bill 通过 CollectionOrderBill"。 + +## 与 adhoc 模块的对比 + +[[../adhoc/collection-order-and-receipt|adhoc 的 CollectionOrder 与 Receipt]] 概念: + +| 维度 | adhoc(一次性收费)| **billing(账单)** | +|---|---|---| +| 主对象 | `AdHocEvent` | `Bill` | +| 与 CollectionOrder 关系 | 1:1(一次买卖一个 CO)| **多对多(中间表)** | +| 是否支持部分付 | ❌(一次性付清)| **✅ Partial 状态** | +| 是否支持批量付 | ❌(每单独立)| **✅ BatchCollectPayment** | +| 收款类型 | `CollectionType=AdHoc` | `CollectionType=Bill` | + +bill 的多对多设计让**批量收款 + 部分付**成为可能,这是周期账单业务的核心需求。 + +## 常见问题 + +> [!question] 为什么不直接在 Bill 上记 paid_amount,不要中间表? +> 因为要记**每笔付款的细节**(什么时候付、什么方式、多少金额)。中间表才能存这些。 +> +> 单纯 `paid_amount` 字段只能表达"总付了多少",无法回答"业户上次付的时候用的什么方式"。 + +> [!question] 一个 CollectionOrder 关联到一个 Bill 但 allocated_amount < CO.actual_amount 怎么办? +> 业务上不应该发生(每笔 CO 应该被完全分配)。如果发生,差额部分是"未分配收款" → 需要后续补建 CollectionOrderBill 关联其他 Bill(或留作业户预付,看业务设计)。 + +> [!question] Bill.paid_amount 字段从哪算? +> 系统应自动维护:`paid_amount = SUM(collectionOrderBills.allocated_amount)`。每次新建 CollectionOrderBill 时回填 Bill.paid_amount。 +> +> 若数据库直接改 collectionOrderBills 没回填(理论上不应该,但若有 bug)→ Bill.paid_amount 与实际不符 → 需修复 + 审计。 + +> [!question] 已付账单作废后,关联的 CollectionOrderBill 怎么办? +> 看实现: +> - **不动**(保留历史关联)+ 走"作废 + 退款"组合(建红字 CO 退给业户) +> - **解除关联**(allocated_amount 退还,但这种做法破坏审计) +> +> 推荐**前者**(保留历史 + 走退款)。详见 [[void-paid-bill]]。 + +> [!question] CollectionOrderBill 的允许的 allocated_amount 大于 Bill.amount 吗? +> 业务上不应该(超额付了无意义)。如果业户付多了: +> - 多余部分**应该退**(或转入预存款) +> - 不应该让 CollectionOrderBill.allocated_amount > Bill.amount(等于"账单凭空多了钱") + +## 相关文档 + +- [[bill-six-state-machine]] +- [[bill-types-and-sources]] +- [[collect-payment-single]] +- [[collect-payment-batch]] +- [[collect-via-prepaid-auto]] +- [[exception-partial-payment]] +- [[../adhoc/collection-order-and-receipt]] +- [[../prepaid/consume-via-bill-collection-type]]