diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index fa27a02..b40c929 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -197,6 +197,10 @@ }, "active": "849c5ff8936a2b67", "lastOpenFiles": [ + "prop-acc/scenarios/billing/delete-bill-unpaid.md", + "prop-acc/scenarios/billing/resume-bill.md", + "prop-acc/scenarios/billing/suspend-bill.md", + "prop-acc/scenarios/billing/split-bill.md", "prop-acc/scenarios/billing/collect-via-prepaid-auto.md", "prop-acc/scenarios/billing/collect-payment-batch.md", "prop-acc/scenarios/billing/collect-payment-single.md", @@ -221,10 +225,6 @@ "prop-acc/scenarios/meter/read-with-photo-proof.md", "prop-acc/scenarios/meter/read-via-iot-remote-source.md", "prop-acc/scenarios/meter/read-batch-via-excel-import.md", - "prop-acc/scenarios/meter/read-single-meter-manual.md", - "prop-acc/scenarios/meter/decommission-without-replacement.md", - "prop-acc/scenarios/meter/replace-broken-meter.md", - "prop-acc/scenarios/meter/register-single-meter.md", "prop-acc/scenarios/meter", "prop-acc/concepts/meter", "prop-acc/scenarios/prepaid", diff --git a/prop-acc/scenarios/billing/delete-bill-unpaid.md b/prop-acc/scenarios/billing/delete-bill-unpaid.md new file mode 100644 index 0000000..e3cff69 --- /dev/null +++ b/prop-acc/scenarios/billing/delete-bill-unpaid.md @@ -0,0 +1,223 @@ +--- +title: prop-acc · billing · 场景 - 物理删除未付账单(误建立刻删) +aliases: + - 物理删除账单 + - 删除未付账单 + - delete-bill-unpaid + - 场景-删除未付账单 +tags: + - 场景 + - prop-acc + - 账单 + - 删除 +audience: + - 业务人员 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 场景:物理删除未付账单(误建立刻删) + +业务人员**误建**了账单(选错业户 / 写错金额 / 误触发批量生成),**立即发现**且**业户还没付**任何钱 → 走**物理删除**。`canBeDeleted()` 守护:Unpaid + 无付款关联。 + +## 典型情境 + +> [!example] 真实情境 +> 王主管月初手工建账单"5 月物业费 ¥800",**填错业户 ID**(应该是张阿姨,选成了陈先生)。提交后立刻发现错误。 +> +> - 账单状态:Unpaid +> - 业户没付过任何钱 +> - **可走物理删除**(走 [[delete-vs-void-dual-track|双轨制]] 的 delete 路径) +> - 再重新建一张正确的(选张阿姨) + +## 业务人员视角 + +### 第 1 步:发现错误 + +| 发现时机 | 处置 | +|---|---| +| **立即**(几秒钟内)| 物理删(本场景)| +| **几小时**(可能业户已收到推送)| 仍可删(若 Unpaid + 无付款)| +| **几天**(可能业户已付)| **不可删**,走 [[void-paid-bill|作废]] | + +### 第 2 步:打开账单 + +后台 → 账单 → 找到误建账单 → 进 `EditBill`(或 `ViewBill`)。 + +### 第 3 步:点击 `DeleteAction`(标签"删除") + +> [!warning] 按钮可见性 +> 守护:`canBeDeleted()` = Unpaid + 无付款 + `->authorize('delete')`(`bill.delete` 权限)。 +> +> 任何其他状态(Partial / Paid / Suspended / Void)或有任何付款关联 → 按钮**灰化**。 + +### 第 4 步:Modal 确认 + +``` +确认删除账单 #B-202605-501-001? + +⚠️ 此账单状态为 Unpaid 且无付款关联,可物理删除。 + 删除后无法恢复(但 activitylog 会保留记录)。 + +[取消] [确认删除] +``` + +### 第 5 步:提交 + +系统: + +1. 再次校验 `canBeDeleted()`(防 UI 缓存过期) +2. 物理删 Bill(从数据库消失) +3. 写 activitylog(event=deleted,记 bill_no / amount / 操作员 / 时间) + +> [!info] activitylog 留什么 +> 即使 Bill 物理删了,activitylog 表还有完整记录: +> +> ```sql +> SELECT * FROM activity_log +> WHERE event = 'deleted' +> AND properties->>'$.bill_no' = 'B-202605-501-001'; +> ``` +> +> 可看到"谁在什么时候删了什么账单"。详见 [[smart-bulk-delete-design]]"activitylog 设计"。 + +### 第 6 步:重新建正确账单(若需要) + +走 [[create-single-bill-manual|手动建单]] 流程,选正确业户。 + +## 系统流程 + +```mermaid +sequenceDiagram + participant 业务 + participant Filament + participant Action[DeleteAction] + participant Bill + participant Activity + participant DB + + 业务->>Filament: EditBill → DeleteAction + Filament->>Action: handle(bill) + Action->>Bill: canBeDeleted()? Unpaid + 无付款 = true + Action->>Activity: log(event=deleted, properties={bill_no, amount, ...}) + Action->>DB: 物理删 Bill + Action-->>Filament: 跳回 ListBills +``` + +## 业户视角 + +业户**无感**: + +- 误建的账单(尤其几秒内删的)业户**根本不知道**它存在过 +- 即使业户已经收到推送,删除后再次刷新看不到了 +- 物业可选择给业户发"前述账单作废,系统误建"(可选,看业务策略) + +**最理想场景**:业务人员发现 → 立即删 → 业户从未感知。 + +## `canBeDeleted` 详解 + +`Bill::canBeDeleted()`: + +```php +public function canBeDeleted(): bool +{ + return $this->status === BillStatus::Unpaid + && ! $this->hasAnyPayment(); +} +``` + +`hasAnyPayment()`: + +```php +public function hasAnyPayment(): bool +{ + return $this->collectionOrderBills()->exists() + || $this->prepaidTransactionsRelatedToBill()->exists(); +} +``` + +两条都为真(Unpaid + 无付款关联) → 可删。 + +> [!info] 为什么 Partial 不能删? +> Partial 意味着业户已经付了一部分 → 有 CollectionOrderBill 关联 → `hasAnyPayment=true` → 拒绝物理删。 +> +> 业户付的钱不能"凭空消失"。Partial 状态要么继续收款 → Paid;要么走 [[void-paid-bill|作废 + 退款]] 流程。 + +## 与作废的对比 + +| 维度 | **删除(本场景)** | [[void-paid-bill|作废]] | +|---|---|---| +| 适用 | Unpaid + 无付款 | 非 Paid 非 Void(基本是 Partial / Suspended) | +| 数据库 | **从数据库消失** | 留状态 = Void | +| activitylog | 留(event=deleted)| 留(event=voided)| +| 业户感知 | 通常无感(未付过)| 有(看到状态 Void) | +| 可恢复 | ❌ 不可(数据真没了)| ❌ 不可(Void 终态)| +| 适用场景 | 误建立刻删 | 任何已付 / 有付款 / 业户已知的 | + +详见 [[delete-vs-void-dual-track]] 双轨制设计哲学。 + +## 批量删除(误建一批) + +如果不是单张误建,而是**误触发批量生成**(例如点了一次"生成 5 月物业费"后忘了又点了一次): + +→ 走 [[bulk-delete-batch-mistake|智能批量删除]],走 `BulkDeleteBillsAction` 的预检查 + 三档分类。 + +## 常见问题 + +> [!question] 删了后悔了能恢复吗? +> 不能(数据真的没了)。**只能重新建**(走 [[create-single-bill-manual]] 或 [[create-periodic-property-fee]])。 +> +> 但**所有信息可从 activitylog 还原**(原 bill_no / amount / 业户 等),重建相对简单。 + +> [!question] 业务人员误删大量账单怎么办? +> 单删一次一张,影响有限。**批删**才容易误删大量(详见 [[smart-bulk-delete-design]] 的预检查防护)。 +> +> 单张误删的预防: +> - Modal 确认(默认有) +> - 思考"是否真要删"再点 + +> [!question] 删除有审批吗? +> 当前**无审批流**(单签批操作)。但**权限层有控制**:`bill.delete` 是普通业务人员权限;`bill.bulkDelete` 是高敏独立权限(详见 [[smart-bulk-delete-design]])。 + +> [!question] activitylog 表会爆掉吗? +> 长期看会(每次单删 + 批删 + 作废 + 收款都加几条)。需归档策略(详见 [[smart-bulk-delete-design]]"常见问题"段)。 + +> [!question] 删除影响 reading.bill_id 吗(若是计量账单)? +> 看 cascade 设计: +> - 若 reading 的 `bill_id` 是 FK + cascade SET NULL → 自动 nullify +> - 若没 cascade → reading.bill_id 仍指向已删 Bill(孤儿 FK,需修复) +> +> 当前实施看代码。**预防**:计量账单不轻易物理删,走作废更稳妥。 + +> [!question] 已删账单的 activitylog 怎么用? +> ```sql +> -- 某员工某天的全部 delete +> SELECT +> id, causer_id, +> properties->>'$.bill_no' AS bill_no, +> properties->>'$.amount' AS amount, +> created_at +> FROM activity_log +> WHERE event = 'deleted' +> AND causer_id = ? +> AND created_at BETWEEN ? AND ?; +> ``` + +## 异常分支 + +- 误删后想重建 → [[create-single-bill-manual]] +- 误删一批 → 走批删的反向(无,只能 1 张张重建) +- 不能删(有付款)→ [[void-paid-bill]] +- 批量误建 → [[bulk-delete-batch-mistake]] + +## 相关文档 + +- [[delete-vs-void-dual-track]] +- [[smart-bulk-delete-design]] +- [[bill-six-state-machine]] +- [[void-paid-bill]] +- [[bulk-delete-batch-mistake]] +- [[audit-activitylog-trace]] +- [[create-single-bill-manual]] diff --git a/prop-acc/scenarios/billing/resume-bill.md b/prop-acc/scenarios/billing/resume-bill.md new file mode 100644 index 0000000..ded3e23 --- /dev/null +++ b/prop-acc/scenarios/billing/resume-bill.md @@ -0,0 +1,253 @@ +--- +title: prop-acc · billing · 场景 - 恢复挂起的账单 +aliases: + - 恢复账单 + - ResumeBillAction + - 解除挂起 + - resume-bill + - 场景-恢复挂起账单 +tags: + - 场景 + - prop-acc + - 账单 + - 调整 +audience: + - 业务人员 + - 财务 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 场景:恢复挂起的账单 + +[[suspend-bill|挂起]] 状态的账单,在**纠纷解决 / 业户回来**后恢复到 Unpaid / Partial,后续可正常收款。`ResumeBillAction` 对称于 SuspendBillAction。 + +## 典型情境 + +> [!example] 真实情境(一):业户回来了 +> 王先生(15-7-203)出国 3 个月后回来,到物业前台:"我去美国出差 3 个月,没顾上缴物业费,现在补"。 +> +> - 王主管查看王先生账户 → 3 张挂起的物业费(Suspended) +> - 走 `ResumeBillAction` 逐张恢复 → 状态 Suspended → Unpaid +> - 然后走 [[collect-payment-batch|批量收款]] 一次性 ¥2,400 付清 + +> [!example] 真实情境(二):纠纷解决 +> 陈先生与物业 5 月物业费纠纷调解结果:物业有部分过错,**协议金额 ¥600**(而不是原 ¥800)。 +> +> - 物业要做: +> - 走 ResumeBill(挂起 → Unpaid) +> - 改账单金额(Edit Bill,若 Policy 允许 update 字段)/ 或作废原账单 + 重建 ¥600 账单 +> - 业户付 ¥600 → 走 [[collect-payment-single]] + +## 业务人员视角 + +### 第 1 步:确认恢复场景 + +| 场景 | 后续 | +|---|---| +| 业户失联回来要付 | 恢复 → 收款 | +| 纠纷解决(物业胜)| 恢复 → 收原金额 | +| 纠纷解决(妥协)| 恢复 → 改金额(或作废+重建) | +| 误挂起 | 恢复(reason = "误操作解除") | + +### 第 2 步:打开账单 + +后台 → 账单 → 过滤"状态=Suspended" → 找到目标账单 → 进 `ViewBill`。 + +状态显示 "🧊 Suspended",右上角只有 `ResumeBillAction` 和 `VoidBillAction` 可点。 + +### 第 3 步:点击 `ResumeBillAction`(标签"恢复") + +> [!warning] 按钮可见性 +> 守护:`bill.status === Suspended` + Policy `->authorize('resume')`。 + +Modal 表单: + +| 字段 | 填什么 | +|---|---| +| **恢复原因(reason)** | 必填,如 "业户出差回来,主动付清"| + +### 第 4 步:提交 + +`ResumeBillAction` 业务层逻辑: + +```php +class ResumeBillAction +{ + public function handle(Bill $bill, string $reason, User $user): void + { + if ($bill->status !== BillStatus::Suspended) { + throw new RuntimeException("账单非 Suspended 状态,不可恢复"); + } + + // 智能恢复:有部分付 → Partial;无付款 → Unpaid + $newStatus = $bill->paid_amount > 0 + ? BillStatus::Partial + : BillStatus::Unpaid; + + $bill->update([ + 'status' => $newStatus, + 'meta' => array_merge($bill->meta ?? [], [ + 'resume_reason' => $reason, + 'resumed_at' => now(), + 'resumed_by' => $user->id, + // 可选:把这次"挂起-恢复"完整记录追加到 suspend_history 数组 + ]), + ]); + + activity() + ->performedOn($bill) + ->causedBy($user) + ->withProperties([ + 'reason' => $reason, + 'from_status' => BillStatus::Suspended->value, + 'to_status' => $newStatus->value, + 'bill_no' => $bill->bill_no, + ]) + ->event('resumed') + ->log('账单已恢复'); + } +} +``` + +### 第 5 步:通知业户(可选) + +恢复后立即提示业户付款: + +> 王先生,您的 3 张挂起账单已恢复(合计 ¥2,400),现在可以付清。 + +### 第 6 步:走收款 + +恢复后走 [[collect-payment-single]] 或 [[collect-payment-batch]]。 + +## 系统流程 + +```mermaid +sequenceDiagram + participant 业户 + participant 业务 + participant Filament + participant Action[ResumeBillAction] + participant DB + + 业户->>业务: 我要付挂起的账单 + 业务->>Filament: ViewBill(Suspended)→ ResumeBillAction(modal, reason) + Filament->>Action: handle(bill, reason, user) + Action->>Action: 校验 status === Suspended + Action->>Action: 判定恢复后状态(Unpaid 或 Partial) + Action->>DB: 1. bill.status = Unpaid / Partial + Action->>DB: 2. bill.meta.resume_reason / resumed_at / resumed_by + Action->>Activity: 3. log(event=resumed) + Filament-->>业务: 成功 + + 业务->>Filament: 继续走收款流程 +``` + +## 智能恢复:Partial vs Unpaid + +`ResumeBillAction` 判断恢复到哪个状态: + +| 挂起前的状态 | 恢复后的状态 | +|---|---| +| Unpaid(无付款)→ Suspended | **Unpaid** | +| Partial(部分付)→ Suspended | **Partial** | + +代码层用 `paid_amount > 0` 判断。这样恢复后业户的"已付部分"还在账户上。 + +## 多次"挂起-恢复"的历史记录 + +如果同一账单被多次挂起 / 恢复: + +```json +// Bill.meta 推荐结构(看实现是否如此) +{ + "suspend_reason": "最近一次挂起原因", + "suspended_at": "最近一次挂起时间", + "resume_reason": "最近一次恢复原因", + "resumed_at": "最近一次恢复时间", + "suspend_history": [ + { + "suspended_at": "...", + "suspended_by": "...", + "suspend_reason": "...", + "resumed_at": "...", + "resumed_by": "...", + "resume_reason": "..." + }, + { ...第二次... }, + ... + ] +} +``` + +第一次"挂-恢复"完整存进 `suspend_history` 数组,新一次的"挂"覆盖 `suspend_reason`/`suspended_at`。完整审计可追溯。 + +> [!info] 实施细节 +> 当前 `SuspendBillAction` / `ResumeBillAction` 是否实现 `suspend_history` 数组看代码。简版实现可能只覆盖最近一次(无 history)。 + +## 业户视角 + +### 您会感受到什么 + +- 收到通知"您的账单已恢复,请尽快付款" +- 小程序"我的账单"看到状态:Suspended → Unpaid +- 后续付款流程同正常 + +### 业户配合 + +业户应: + +- 立即付款(避免再次进入逾期) +- 若有付款困难,提前告诉物业(可能再次挂起 + 协商) + +## 与其他模块的对比 + +| 模块 | 类似 Suspend / Resume | +|---|---| +| **billing(本)** | SuspendBillAction / ResumeBillAction | +| deposit | freeze / unfreeze(账户级,详见 [[../deposit/unfreeze-after-mediation]]) | +| prepaid | FreezeAccountAction / ReactivateAccountAction([[../prepaid/unfreeze-after-verification]]) | +| meter | 无(meter 用 decommission,不可恢复)| + +**billing / deposit / prepaid 都有"挂起 / 恢复"的对偶设计** —— 这是金融类业务的通用模式。 + +## 常见问题 + +> [!question] 恢复后业户又找不到了怎么办? +> 走 [[exception-overdue-bills|逾期催收]] → 多次催不到 → 再 [[suspend-bill|挂起]] 一次(reason 改"再次失联")。多次"挂-恢复"循环说明业户有问题,需法律 / 走绕监管路径。 + +> [!question] 恢复时账单期次已经过去很久(例如 5 月账单 11 月才恢复),还按原 due_at 算逾期吗? +> 看业务策略: +> +> - 严格:仍按原 due_at(5 月底 + 宽限期)→ 一恢复就是逾期(可能加滞纳金) +> - 宽松:挂起期间不算逾期 → 恢复时给新的宽限期(例如恢复后 + 7 天) +> +> 当前实施看 `OverdueBillsListWidget` 的判断逻辑。 + +> [!question] 误挂起立即恢复,activitylog 显示两条(suspended + resumed)对吗? +> 对。每次状态变化各一条 log。可在 reason 备注"误操作 + 立即恢复"。 + +> [!question] 恢复后能直接改金额吗? +> 看 Policy / EditBill。Unpaid 状态可能允许 Edit(改 amount)。Partial 状态(已付款部分)改金额复杂(原 paid_amount 怎么算)→ 不推荐改,改用"作废 + 重建"。 + +> [!question] 恢复操作需要审批吗? +> 当前**无审批流**(单签批操作)。业务上若需要审批(例如金额大),靠人员制度保障(高权限人员才操作)。 + +## 异常分支 + +- 误恢复(应作废)→ [[void-paid-bill]] 走作废路径 +- 恢复后改金额 → 复杂,走作废 + 重建([[create-single-bill-manual]]) +- 长期挂起最终决定作废 → [[void-paid-bill]] + +## 相关文档 + +- [[suspend-bill]] +- [[bill-six-state-machine]] +- [[void-paid-bill]] +- [[collect-payment-single]] +- [[exception-overdue-bills]] +- [[audit-activitylog-trace]] +- [[../deposit/unfreeze-after-mediation]](deposit 同类对比) +- [[../prepaid/unfreeze-after-verification]](prepaid 同类对比) diff --git a/prop-acc/scenarios/billing/split-bill.md b/prop-acc/scenarios/billing/split-bill.md new file mode 100644 index 0000000..7e6e3c6 --- /dev/null +++ b/prop-acc/scenarios/billing/split-bill.md @@ -0,0 +1,220 @@ +--- +title: prop-acc · billing · 场景 - 拆账单(SplitBillAction) +aliases: + - 拆账单 + - 账单分摊 + - SplitBillAction + - split-bill + - 场景-拆账单 +tags: + - 场景 + - prop-acc + - 账单 + - 调整 +audience: + - 业务人员 + - 财务 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 场景:拆账单(SplitBillAction) + +业户**多人共住一户**(房东 + 租户 / 合租),物业费 / 水电气**按比例分摊**到各自账户,业务人员走 `SplitBillAction` 把一张账单**拆成多张**。 + +## 典型情境 + +> [!example] 真实情境 +> 12-3-501 房屋: +> +> - 房东陈先生(产权人) +> - 租户李先生(2026 年 1 月起租) +> +> 合同约定:**物业费房东付,水电气租户付**。但系统月初按"业户=陈先生"批量生成的: +> +> - 5 月物业费 ¥800(应给陈先生) +> - 5 月水费 ¥54(实际应租户付) +> - 5 月电费 ¥168(实际应租户付) +> - 5 月燃气 ¥30(实际应租户付) +> +> 业务人员王主管要把后 3 张账单**重新分给租户李先生**(从陈先生的账户拆出去)。 + +## 业务人员视角 + +### 第 1 步:确认拆单需求 + +业户来电话 / 合同约定: + +- 房东本人来报备"水电气以后租户付" +- 看合同 / 租赁备案 +- 确认租户身份(可能已在 `community_user_profiles` 注册) + +### 第 2 步:逐张拆单 + +**(本场景默认逐张拆,不是一次 SplitAll)** + +后台 → 账单 → 找到 5 月水费 Bill → 进 `ViewBill` → 点 `SplitBillAction`(标签"拆账单")。 + +> [!warning] 按钮可见性 +> 看 `SplitBillAction` 守护(应该是 Unpaid + 业务权限)。Paid / Void 状态可能不允许拆(已付的钱难撤)。 + +### 第 3 步:Modal 填参数 + +| 字段 | 填什么 | +|---|---| +| **原账单** | 5 月水费 ¥54(陈先生)| +| **拆分方式** | 按比例 / 按金额 / 按目标业户 | +| **新业户** | 李先生(下拉选)| +| **拆给新业户的金额** | ¥54(全部 → 全转给李先生 = 业户更换;或部分 → 拆成两张)| +| **拆分原因** | 必填,如 "房东与租户合同约定:水费由租户付"| + +### 第 4 步:提交 + +`SplitBillAction` 业务逻辑(看实现): + +**模式 1:全转给新业户**(本场景常见) + +``` +- 原 Bill #X(陈先生):状态翻 Void(原账单作废)+ 标 split_to=新 Bill ID +- 新建 Bill #Y(李先生):amount=54, status=Unpaid +- 关联(meta 记拆分来源) +``` + +**模式 2:部分拆分**(罕见) + +``` +- 原 Bill #X(陈先生):amount 改为 30(原 54 - 拆出 24) +- 新建 Bill #Y(李先生):amount=24 +- 两张同存 +``` + +> [!info] 实施细节看代码 +> `SplitBillAction` 的具体行为(全转 vs 部分拆 vs 按比例)看 `packages/prop-acc/src/Actions/Bills/SplitBillAction.php`。本文按业务场景描述。 + +### 第 5 步:通知双方 + +- 陈先生:"5 月水费已转给租户李先生付,您不必付了" +- 李先生:"您 5 月水费 ¥54 新账单已生成,请于 6 月 15 日前付清" + +### 第 6 步:重复对电费 / 燃气 + +逐张拆。3 张全拆完后: + +- 陈先生只欠 5 月物业费 ¥800 +- 李先生欠 5 月水费 + 电费 + 燃气 ¥252 + +## 系统流程(全转模式) + +```mermaid +sequenceDiagram + participant 业务 + participant Filament + participant Action[SplitBillAction] + participant DB + participant Activity[activitylog] + + 业务->>Filament: ViewBill(陈先生水费)→ SplitBillAction + Filament->>Action: handle(bill=陈先生水费, mode=Full, target=李先生, reason) + Action->>Action: 校验 bill.status (Unpaid) + Action->>Action: 校验 target_resident 在同 community + + Action->>DB: 开启事务 + Action->>DB: 1. 原 Bill 翻 Void + meta.split_to=新 ID + Action->>DB: 2. 新建 Bill(target=李先生, amount=54, status=Unpaid, meta.split_from=原 ID) + Action->>Activity: log(event=split, properties) + Action->>DB: 提交 + + Filament-->>业务: 跳转新 Bill +``` + +## 业户视角 + +### 陈先生(原账单业户) + +收到通知: + +> 陈先生您好,您的 5 月水费 ¥54 已根据合同拆给租户李先生付。您原账单已作废,无需付款。 + +### 李先生(新账单业户) + +收到通知: + +> 李先生您好,您 5 月水费 ¥54 账单已生成(由 12-3-501 房屋的合同拆分而来),请于 6 月 15 日前付清。 + +## 拆单的几种业务场景 + +| 场景 | 频率 | 拆法 | +|---|---|---| +| **房东 / 租户合同分摊** | 中(本场景)| 按费用类型分,水电气给租户 | +| **多业主分摊**(共有产权)| 罕见 | 按比例 | +| **公摊费用追溯** | 中(本月才发现某费用应分摊)| 按户数 | +| **企业内部子公司分摊**(商铺)| 罕见 | 按合同 | + +## 与"作废 + 重建"的对比 + +| 维度 | 拆账单(SplitBillAction)| 作废 + 手动重建 | +|---|---|---| +| 单一操作 | ✅(一个 Action 完成)| ❌(走 2-3 个 Action)| +| 关联追溯 | ✅(meta 标 split_from/to)| ❌(无系统关联)| +| 审计 | activitylog event=split | 多条 activitylog(void + create)| +| 灵活性 | 受 SplitBillAction 限制 | 更灵活(可改任意字段)| +| 推荐场景 | 简单拆分(全转 / 部分按金额)| 复杂拆分 / 拆完还要改其他字段 | + +## 拆单后已付款的复杂情况 + +> [!warning] 已付款 Bill 拆单的复杂度 +> +> 如果原账单**已付一部分**(Partial 状态): +> +> - 原账单作废 → 已付的部分怎么办? +> - 退还给原业户 → 走作废 + 退款([[void-paid-bill]] 类似) +> - 新账单上记 paid_amount? 不行(原业户付的钱不该算到新业户) +> +> 实施上 `SplitBillAction` 可能**不允许 Partial 状态拆**(只允许 Unpaid)。看具体实现。 +> +> **推荐**:拆单前先处理已付款(作废 + 退款 / 退现金后)再拆。 + +## 常见问题 + +> [!question] 拆给的业户不存在(系统里没注册)怎么办? +> 先建业户档案(community 模块的 `community_user_profiles`)→ 然后拆。 + +> [!question] 拆错了(应该拆给李先生,选成王先生)? +> 看实施: +> - 若新 Bill 仍 Unpaid → 走 [[delete-bill-unpaid|删除]] + 重拆 +> - 若已 Paid → 麻烦,需走作废 + 退款 + 重拆 + +> [!question] 按比例自动拆所有账单(房东 50% / 租户 50% 物业费)? +> 当前 `SplitBillAction` 应是**逐张操作**。按比例批量拆需要业务方提需求 + 加 `BulkSplitBillsAction`(待实现)。 + +> [!question] 拆单的合规性? +> 物业要确保: +> - 业户合同明确分摊条款 +> - 双方书面同意拆分 +> - 物业内部审批留底 +> +> 系统层面只管账单数据,合规由物业流程保障。 + +> [!question] 一张账单能拆成 3+ 份吗(多人合租 3 人均摊)? +> 看 SplitBillAction 设计。简单实现是**一次拆 2 份**(原账单 + 新账单)。要拆 3 份需 2 次操作: +> +> 1. 第 1 次:原账单(¥54)→ 拆出 ¥18(给租户 A),原账单剩 ¥36 +> 2. 第 2 次:原账单(¥36)→ 拆出 ¥18(给租户 B),原账单剩 ¥18(给房东自己) +> +> 累加得到三份 ¥18 各自归一人。 + +## 异常分支 + +- 拆错了 → 删除 / 作废新 Bill,原 Bill 状态可能要恢复(看实施) +- 已付的拆 → 走 [[void-paid-bill]] 流程,复杂 +- 简单"换业户"(不拆,只改账单的 resident_id)→ 可能用 Edit Bill 直接改(若 Policy 允许),但**强烈不推荐**(破坏追溯)→ 用拆单更标准 + +## 相关文档 + +- [[bill-six-state-machine]] +- [[delete-bill-unpaid]] +- [[void-paid-bill]] +- [[suspend-bill]] +- [[bill-vs-collection-order]] diff --git a/prop-acc/scenarios/billing/suspend-bill.md b/prop-acc/scenarios/billing/suspend-bill.md new file mode 100644 index 0000000..c6076d1 --- /dev/null +++ b/prop-acc/scenarios/billing/suspend-bill.md @@ -0,0 +1,241 @@ +--- +title: prop-acc · billing · 场景 - 挂起账单(业户失联/纠纷) +aliases: + - 挂起账单 + - SuspendBillAction + - 暂停收款 + - suspend-bill + - 场景-挂起账单 +tags: + - 场景 + - prop-acc + - 账单 + - 调整 +audience: + - 业务人员 + - 财务 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 场景:挂起账单(业户失联 / 纠纷) + +业户**与物业有纠纷**或**长期失联**,该账单暂时**不应被收款 / 不应被催收**,但又不能直接作废(纠纷可能解决,业户可能出现)。走 `SuspendBillAction` 把账单挂起,状态 Unpaid → Suspended,后续可 [[resume-bill|恢复]]。 + +## 典型情境 + +> [!example] 真实情境(一):业户失联 +> 王先生(15-7-203)三个月没缴物业费(累计 3 张账单 ¥2,400 Unpaid),物业多次联系电话不通、上门无人。物业认为业户可能搬走 / 失联 / 出国,**先把这 3 张账单挂起**,避免: +> +> - 月度报表"应收账款"虚高(挂着收不回来) +> - 催收资源浪费(联系不上的还反复发短信) +> - 业户突然回来时账单仍在(不会变作废) + +> [!example] 真实情境(二):纠纷期间 +> 陈先生认为 5 月物业费 ¥800 不合理(物业服务质量纠纷),拒绝付。物业 / 业主委员会调解中,**挂起该账单**,等调解结果: +> - 调解物业胜诉 → [[resume-bill|恢复]] → 业户付 +> - 调解业户胜诉 → [[void-paid-bill|作废]] → 不收 +> - 调解妥协 → 走拆账单 / 重新算金额 + +## 业务人员视角 + +### 第 1 步:确认挂起场景 + +| 场景 | 是否走挂起 | +|---|---| +| 业户失联 1-3 个月 | ✅ 挂起(等业户出现) | +| 业户失联 >6 个月 | 可考虑作废(看物业政策)| +| 业户纠纷中 | ✅ 挂起 | +| 业户拒不付 | 不挂起,走逾期催收([[exception-overdue-bills]]) | +| 业户真的搬走永久不再来 | 走作废 / 法律手段 | + +### 第 2 步:打开账单 + +后台 → 账单 → 找到 Unpaid Bill → 进 `ViewBill`。 + +### 第 3 步:点击 `SuspendBillAction`(标签"挂起") + +> [!warning] 按钮可见性 +> 守护:`bill.status === Unpaid || Partial` + Policy `->authorize('suspend')`。Paid / Void / Suspended 状态灰化。 + +Modal 表单: + +| 字段 | 填什么 | +|---|---| +| **挂起原因(reason)** | **必填且详细**,如 "业户失联 3 个月,电话不通 + 上门无人 + 微信无回应" | + +### 第 4 步:提交 + +`SuspendBillAction` 业务层逻辑: + +```php +class SuspendBillAction +{ + public function handle(Bill $bill, string $reason, User $user): void + { + if (! in_array($bill->status, [BillStatus::Unpaid, BillStatus::Partial])) { + throw new RuntimeException("账单状态不可挂起"); + } + + $bill->update([ + 'status' => BillStatus::Suspended, + 'meta' => array_merge($bill->meta ?? [], [ + 'suspend_reason' => $reason, + 'suspended_at' => now(), + 'suspended_by' => $user->id, + ]), + ]); + + activity() + ->performedOn($bill) + ->causedBy($user) + ->withProperties([ + 'reason' => $reason, + 'from_status' => $bill->getOriginal('status'), + 'to_status' => BillStatus::Suspended->value, + 'bill_no' => $bill->bill_no, + ]) + ->event('suspended') + ->log('账单已挂起'); + } +} +``` + +### 第 5 步:通知业户(可选) + +- 失联场景:不通知(联系不上,无意义) +- 纠纷场景:通知"您的账单已挂起,等调解结果" + +## 系统流程 + +```mermaid +sequenceDiagram + participant 业务 + participant Filament + participant Action[SuspendBillAction] + participant DB + participant Activity + + 业务->>Filament: ViewBill → SuspendBillAction(modal) + Filament->>Action: handle(bill, reason, user) + Action->>Action: 校验 status Unpaid/Partial + Action->>DB: 开启事务 + Action->>DB: 1. bill.status=Suspended + Action->>DB: 2. bill.meta.suspend_reason / suspended_at / suspended_by + Action->>Activity: 3. log(event=suspended) + Action->>DB: 提交 + + Filament-->>业务: 成功 +``` + +## 挂起后的能力对照 + +| 操作 | Unpaid / Partial | **Suspended** | +|---|---|---| +| `CollectPaymentAction`(收款)| ✅ | ❌(`canBePaid=false`)| +| `CollectPaymentAction`(预存款抵)| ✅ | ❌ | +| `SuspendBillAction`(再挂起)| ✅ | ❌(已是 Suspended)| +| `ResumeBillAction`(恢复)| ❌ | ✅ | +| `VoidBillAction`(作废)| ✅(canBeVoided=true)| ✅ | +| 看账单 / 看历史 | ✅ | ✅(只读)| +| 出现在 `OverdueBillsListWidget` | ✅(若到期)| ❌(挂起不算逾期)| +| 出现在月度账单清单 | ✅ | ✅(标 Suspended)| + +> [!info] Suspended 与 Overdue 的关系 +> 挂起的账单**不算逾期**(catch up 不会出现在催收清单)。但**期次仍在历史**(归属本月报表)。 +> +> 这是**有意设计**:挂起的目的是停掉催收 + 暂时退出收款流程,但不让账单凭空消失。 + +## 业户视角 + +### 失联场景(无感) + +业户失联本来就联系不上,业务方决定挂起 → 业户**完全不知道**。等业户出现时: + +- 业户来电话 / 上门 +- 业务人员说"您有挂起的账单 ¥XXX,我现在帮您恢复 + 收款" +- 走 [[resume-bill|恢复]] → 收款 + +### 纠纷场景 + +业户**可能收到通知**(看物业政策): + +> 陈先生您好,您的 5 月物业费账单已挂起(暂停收款),等纠纷调解结果。预计 X 月 Y 日前出结果。 + +业户: + +- 知情 + 等待 +- 期间不会被催收 +- 调解后走 [[resume-bill|恢复]] 或 [[void-paid-bill|作废]] + +## 与"作废"的对比 + +| 维度 | **挂起(Suspended,本场景)** | [[void-paid-bill|作废 Void]] | +|---|---|---| +| 是否可恢复 | ✅ 走 [[resume-bill]]| ❌ 终态 | +| 业务场景 | 临时暂停(纠纷 / 失联)| 永久消除 | +| 报表归属 | 仍在本期(标 Suspended)| 标 Void(可过滤) | +| 后续是否能收款 | 恢复后能 | 不能(已 Void)| + +**简单判断**:**不确定后续怎么办 → 挂起**;**确定不再收 → 作废**。 + +## 长期 Suspended 的处理 + +挂起后**长期没恢复 / 没作废**的账单,业务上需要定期 review: + +| 挂起时长 | 推荐处置 | +|---|---| +| < 1 月 | 正常等待 | +| 1-3 月 | 评估业户情况(再次联系)| +| 3-6 月 | 决定:恢复(若有进展)/ 作废(若无希望)| +| > 6 月 | 通常作废(或走法律手段)| + +可加 audit 场景:**"长期挂起账单清单"**(类似 [[../deposit/audit-long-pending-accounts]])。当前 issue.md 未明确实施,可作为未来扩展。 + +## 常见问题 + +> [!question] 挂起原因填得详细到什么程度? +> **越详细越好**。审计要求 + 业户事后查询 + 业务复盘都需要。推荐结构: +> +> - 触发事件(纠纷 / 失联 / 其他) +> - 已采取的联系措施(电话 / 上门 / 微信) +> - 业务上的预期(等调解 / 等业户回来 / 等司法) + +> [!question] 挂起的账单已经有部分付(Partial)? +> 看实施。SuspendBillAction 可能允许(从 Partial → Suspended)。已付的部分**不退**(只是暂停后续收款)。 + +> [!question] 同一业户多张挂起,能批量挂吗? +> 当前**无批量挂起 UI**。逐张走 SuspendBillAction,效率低但可控。 +> +> 可加 `BulkSuspendBillsAction`(类似批删的设计),业务方提需求时实施。 + +> [!question] 挂起影响 prepaid 自动抵扣 job? +> 是。job 应**跳过 Suspended 状态**的账单。设计上看 [[../prepaid/auto-deduction-design]]。 + +> [!question] activitylog 怎么查挂起记录? +> ```sql +> SELECT * FROM activity_log +> WHERE event = 'suspended' +> AND created_at BETWEEN ? AND ? +> ORDER BY created_at DESC; +> ``` +> +> 详见 [[audit-activitylog-trace]]。 + +## 异常分支 + +- 业户出现/纠纷解决 → [[resume-bill]] +- 确定不再收 → [[void-paid-bill]] +- 业务上误挂起 → [[resume-bill]] 撤回(reason 改"误操作") +- 长期挂起无解 → 作废 / 法律手段(待业务方明确) + +## 相关文档 + +- [[bill-six-state-machine]] +- [[resume-bill]] +- [[void-paid-bill]] +- [[exception-overdue-bills]] +- [[audit-activitylog-trace]] +- [[../deposit/freeze-during-dispute]](类似的"暂停"模式对比)