From 5934191115562f878e68f5dc1c4e1c48448bc9e3 Mon Sep 17 00:00:00 2001 From: Willie Date: Tue, 26 May 2026 00:48:12 +0800 Subject: [PATCH] vault backup: 2026-05-26 00:48:12 --- .obsidian/workspace.json | 8 +- .../billing/delete-vs-void-dual-track.md | 320 ++++++++++++++++ .../billing/periodic-bill-generation.md | 254 +++++++++++++ .../billing/smart-bulk-delete-design.md | 359 ++++++++++++++++++ prop-acc/maps/billing-knowledge-map.md | 144 +++++++ prop-acc/maps/knowledge-map.md | 2 +- 6 files changed, 1082 insertions(+), 5 deletions(-) create mode 100644 prop-acc/concepts/billing/delete-vs-void-dual-track.md create mode 100644 prop-acc/concepts/billing/periodic-bill-generation.md create mode 100644 prop-acc/concepts/billing/smart-bulk-delete-design.md create mode 100644 prop-acc/maps/billing-knowledge-map.md diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 4ebfcb4..15f6671 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -197,6 +197,10 @@ }, "active": "849c5ff8936a2b67", "lastOpenFiles": [ + "prop-acc/maps/billing-knowledge-map.md", + "prop-acc/concepts/billing/smart-bulk-delete-design.md", + "prop-acc/concepts/billing/delete-vs-void-dual-track.md", + "prop-acc/concepts/billing/periodic-bill-generation.md", "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", @@ -221,11 +225,7 @@ "prop-acc/concepts/meter/reading-source-and-photo-proof.md", "prop-acc/concepts/meter/bill-generation-pipeline.md", "prop-acc/concepts/meter/multiplier-and-tiered-pricing.md", - "prop-acc/concepts/meter/replacement-chain.md", - "prop-acc/concepts/meter/meter-vs-meter-reading.md", "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", "prop-acc/concepts/prepaid", "prop-acc/scenarios/deposit", diff --git a/prop-acc/concepts/billing/delete-vs-void-dual-track.md b/prop-acc/concepts/billing/delete-vs-void-dual-track.md new file mode 100644 index 0000000..74fe692 --- /dev/null +++ b/prop-acc/concepts/billing/delete-vs-void-dual-track.md @@ -0,0 +1,320 @@ +--- +title: prop-acc · billing · 删除 vs 作废双轨制 +aliases: + - 删除 vs 作废 + - 双轨制 + - delete-vs-void-dual-track + - VoidBillAction + - canBeDeleted + - canBeVoided +tags: + - 概念 + - prop-acc + - 账单 + - 数据治理 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 删除 vs 作废双轨制 + +billing 模块对"账单消失"提供**两条路径**:**物理删除**(Delete,Unpaid + 无付款关联)和 **作废**(Void,留状态 + 留审计)。这是 prop-acc 其他子模块**都没有**的设计:deposit/prepaid/meter 都只支持 Close / Decommission 类的"终态保留"。 + +## 为什么要双轨制 + +业务方提了"想要批量删除功能 + 单张删除功能"。深入讨论后形成双轨: + +| 场景 | 推荐路径 | 理由 | +|---|---|---| +| **误开账单立刻发现**(还没付款)| **物理删除** | 干净,没钱被卷入,删了不留痕(activitylog 仍有) | +| **已生成 + 已收一部分钱**(Partial) | **作废** | 钱已经卷入,删了等于消灭凭证;作废留状态 + 退款 | +| **已生成 + 已付清**(Paid)| **作废(需走退款)** | 同上 | +| **业户失联 / 长期不付** | **挂起(Suspend)** | 暂停状态,不是终态;后续可恢复或作废 | + +## 删除路径 + +### 守护 + +`Bill::canBeDeleted()`: + +```php +public function canBeDeleted(): bool +{ + return $this->status === BillStatus::Unpaid + && ! $this->hasAnyPayment(); +} +``` + +`Bill::hasAnyPayment()` 检查: + +- `CollectionOrderBill`(走收款的关联) +- `PrepaidTransaction`(走预存款抵的关联) + +**Unpaid + 无任何付款关联** = 业户没付过任何钱 + 账单状态干净 → 安全删。 + +### 触发入口 + +| 入口 | UI | +|---|---| +| 单删 | `EditBill` 页面的 `DeleteAction`(`->visible(canBeDeleted)` + `->authorize('delete')`)| +| 批删 | `BulkDeleteBillsAction`(智能 Modal,详见 [[smart-bulk-delete-design]])| + +### Policy 守护 + +```php +// BillPolicy +public function delete(AuthUser $user, Model $record): bool +{ + return $user->can('delete bills') && $record->canBeDeleted(); +} + +public function deleteAny(AuthUser $user): bool +{ + return $user->can('bulkDelete bills'); // 独立高敏权限 +} +``` + +`deleteAny` 是**独立权限**(`bill.bulkDelete`),比单删更敏感(批删一次可能 100+ 张),需单独授权。 + +### 删除后留什么 + +| 留下 | 不留 | +|---|---| +| **activitylog** 记录(谁删了 / 什么时候 / 哪些 bill_no)| **Bill 数据库记录** | +| 业户 / asset 等关联实体不动 | **关联的 CollectionOrderBill**(应该 0 个,因守护保证)| + +activitylog 详见 [[smart-bulk-delete-design]]"activitylog 审计"段。 + +## 作废路径 + +### 守护 + +`Bill::canBeVoided()`: + +```php +public function canBeVoided(): bool +{ + return $this->status !== BillStatus::Paid + && $this->status !== BillStatus::Void; +} +``` + +**非 Paid 非 Void** = 可作废。包括 Unpaid / Partial / Suspended / Processing。 + +> [!warning] Paid 的作废需走专门流程 +> `canBeVoided()` 对 Paid 返 false,因为单纯翻状态会让业户已付的钱"凭空消失"。Paid 账单作废需要**配套退款流程**(未来扩展,目前手工 / tinker 操作)。 +> +> issue.md Q6 未具体规划"Paid 作废"业务流程,留作未来扩展。 + +### 触发入口 + +`VoidBillAction`(Filament UI)挂在 `EditBill` / `ViewBill` / Table 行。Modal 必填**作废原因**(`reason`): + +``` +请填写作废原因(必填,审计留痕): +[多行输入框] + +[取消] [确认作废] +``` + +### 业务 Action 实现 + +`src/Actions/Bills/VoidBillAction.php`(业务层,与 Filament UI 分离): + +```php +class VoidBillAction +{ + public function handle(Bill $bill, string $reason, User $user): void + { + if (! $bill->canBeVoided()) { + throw new RuntimeException("账单状态 {$bill->status->value} 不可作废"); + } + + $bill->update([ + 'status' => BillStatus::Void, + 'meta' => array_merge($bill->meta ?? [], [ + 'voided_reason' => $reason, + 'voided_at' => now(), + 'voided_by' => $user->id, + ]), + ]); + + activity() + ->performedOn($bill) + ->causedBy($user) + ->withProperties([ + 'reason' => $reason, + 'from_status' => $bill->getOriginal('status'), + 'to_status' => BillStatus::Void->value, + 'bill_no' => $bill->bill_no, + 'amount' => $bill->amount, + ]) + ->event('voided') + ->log('账单已作废'); + } +} +``` + +### Policy 守护 + +```php +public function void(AuthUser $user, Bill $bill): bool +{ + return $user->can('void bills') && $bill->canBeVoided(); +} +``` + +### 作废后留什么 + +| 留下 | 改变 | +|---|---| +| **Bill 数据库记录**(只读)| `status = Void` | +| **关联的 CollectionOrderBill**(若有)| 不动 | +| **meta**:voided_reason / voided_at / voided_by | 新增 | +| **activitylog**:event=voided | 新增 | +| **Receipt 等下游凭证** | 不动(已经发出去的不撤销)| + +业户可以看到账单"已作废",物业有完整审计链。 + +## 双轨决策树 + +```mermaid +flowchart TD + A[要让账单"消失"] --> B{账单状态?} + + B -->|Unpaid| C{有付款关联吗?} + B -->|Partial / Suspended / Processing| D[走作废 Void] + B -->|Paid| E[当前 canBeVoided=false
需走专门退款流程
未来扩展] + B -->|Void| F[已经是 Void,无需重复] + + C -->|无| G[物理删除 Delete] + C -->|有| D +``` + +## 业务人员视角 + +### 决策时 + +| 误开发现时间 | 推荐 | +|---|---| +| 几秒内(业户还在面前)| 物理删除(干净)| +| 几小时(可能业户已付)| 先查是否已付:已付 → 作废 + 退;未付 → 删 | +| 几天(已发账单)| 通常作废(留状态让业户知道) | +| 几月(已查到)| 作废 + 走退款流程(若已付) | + +### Modal 操作 + +**单删 Modal**(`EditBill` → DeleteAction): + +``` +确认删除账单 #B-202605-501-001? + +⚠️ 此账单状态为 Unpaid 且无付款关联,可物理删除。 + 删除后无法恢复(但 activitylog 会保留记录)。 + +[取消] [确认删除] +``` + +**作废 Modal**(`VoidBillAction`): + +``` +作废账单 #B-202605-501-001 + +账单状态:Unpaid +账单金额:¥800 + +请填写作废原因(必填): +[多行输入] + +[取消] [确认作废] +``` + +## 业户视角 + +| 操作 | 业户感知 | +|---|---| +| 物理删除 | **无感**(账单从未真正"存在过",未通知业户) | +| 作废 | 收到通知"您的账单 #XXX 已作废,理由 YYY" + 账单状态显示 Void | +| Paid 作废(未来) | 收到通知 + 退款到账 + 红字凭证 | + +## 与 prop-acc 其他模块的对比 + +| 模块 | 删 / 作废 / 退役 / 关账 | +|---|---| +| **deposit** | 无 delete UI(Policy 严格)+ ForceClose(终态)| +| **prepaid** | 无 delete UI + CloseAction(终态)| +| **meter** | 无 delete UI(仅退役 + 严格条件下删空表)+ Decommission | +| **bill(本模块)** | **delete + void 双轨** + Suspend / Resume | + +bill 的双轨是 prop-acc **最完整的"账单消失"设计**。其他模块"没有删,只有 终态"是因为它们的对象天然不该消失(账户、押金、表都是长期实体)。bill 的"账单"本质上是**可消失的事务记录**,所以双轨合理。 + +## 历史:issue.md Q6 的修复 + +> [!info] 修复前 +> `BillPolicy` 几乎是空壳(只有 `getPermissionPrefix`),`DeleteAction` 暴露在 EditBill 页 + `DeleteBulkAction` 暴露在 Table toolbar(`visible(false)` 假关闭),Unpaid + Paid + Void 全状态都能删 → 删 Paid Bill = 业户付的钱凭空消失 + 完全无审计痕迹。 +> +> **风险等级**:严重(类似 deposit / prepaid 第二轮修复的"消灭法律证据"级别)。 + +> [!info] 修复后(issue.md Q6 第一轮) +> +> 1. Bill 模型加 3 辅助方法(`hasAnyPayment` / `canBeDeleted` / `canBeVoided`) +> 2. BillPolicy 补 7 方法(`update / delete / deleteAny / void / collect / suspend / resume`) +> 3. 新增 `VoidBillAction` 业务 Action + Filament UI +> 4. 新增 `BulkDeleteBillsAction` 智能批删(详见 [[smart-bulk-delete-design]]) +> 5. 删除 `DeleteBulkAction` 的 `visible(false)` 反模式 → 替换为真正的智能批删 +> 6. EditAction / DeleteAction 三处加 visible 守护 +> 7. CollectPaymentAction authorize 改为 `->authorize('collect')`(取代跨模型 authorize) +> 8. Plugin.php 扩权限位:`bill.bulkDelete` 独立高敏权限 +> +> 全栈守护(UI / Policy / Action / Model)+ activitylog 审计。 + +## 常见问题 + +> [!question] 为什么不全部走作废,简化设计? +> **物理删的"干净"是有价值的**: +> +> - 误开的账单不留痕,业务人员不慌 +> - 业户看不到"已作废"的诡异状态(从未存在过最自然) +> - 数据库无 Void 状态垃圾堆积 +> +> 但**只对没动钱的账单适用**。一旦动了钱,必须作废留痕,不能"假装从未发生"。 + +> [!question] 作废后能撤销吗? +> 不能(Void 是终态,无 reactivate 路径)。如需"恢复":新建一张同信息的 Bill。 + +> [!question] hasAnyPayment 检查的"付款关联"具体是? +> 看 `Bill::hasAnyPayment()` 实现。通常: +> +> - `collectionOrderBills()->exists()`(有任何分配记录) +> - `prepaidTransactions()->where('related_bill_id', this->id)->exists()`(被 prepaid consume 过) +> +> 任意一个为真 → 有付款关联 → 不可物理删。 + +> [!question] 批量删的智能 Modal 怎么工作? +> 详见专门概念 [[smart-bulk-delete-design]]。 + +> [!question] activitylog 怎么查? +> +> ```sql +> -- 某员工某月的全部批量删除 +> SELECT * FROM activity_log +> WHERE causer_id = ? +> AND event IN ('voided', 'bulk_deleted') +> AND created_at BETWEEN '2026-05-01' AND '2026-05-31'; +> ``` + +## 相关文档 + +- [[bill-six-state-machine]] +- [[smart-bulk-delete-design]] +- [[delete-bill-unpaid]] +- [[void-paid-bill]] +- [[bulk-delete-batch-mistake]] +- [[suspend-bill]] +- [[../meter/decommission-and-locking]](类似"保留 vs 删除"对比) diff --git a/prop-acc/concepts/billing/periodic-bill-generation.md b/prop-acc/concepts/billing/periodic-bill-generation.md new file mode 100644 index 0000000..ce27dfe --- /dev/null +++ b/prop-acc/concepts/billing/periodic-bill-generation.md @@ -0,0 +1,254 @@ +--- +title: prop-acc · billing · 周期账单生成机制 +aliases: + - 周期账单生成 + - 月度物业费生成 + - GeneratePeriodicBillsAction + - BillingMergeStrategy + - 合单策略 +tags: + - 概念 + - prop-acc + - 账单 + - 业务流程 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 周期账单生成机制 + +物业费、停车费、电视费等**按周期(月 / 季 / 年)固定收**的账单,通过 **`GeneratePeriodicBillsAction`** 批量生成。配合 **`BillingMergeStrategy` 枚举**决定"同业户多个费用类型是否合并到一张账单"。 + +## 业务场景 + +每月 1 日,物业为本社区 **300 户业主**生成本月物业费账单(每户 ¥800 起步,按房屋面积浮动)。系统应: + +- 自动算每户的金额(按 RatePlan + 房屋面积) +- 批量建 Bill(300 张) +- 各张 Bill 期次清晰(billing_period 5/1 - 5/31) +- 不重复生成(本月已生成的不再生成) +- 业务人员收到结果报告 + +## 批量生成流程 + +```mermaid +flowchart TD + A[业务人员触发 GeneratePeriodicBillsAction] --> B[Modal 输入参数] + B --> C[参数:期次 / 费用类型 / 社区范围 / 合并策略] + C --> D[扫描候选业户清单] + + D --> E[每户:计算应付金额] + E --> F{已有本期 Bill?} + + F -->|否| G[建新 Bill] + F -->|是,根据策略| H{合并策略} + + H -->|MERGE 合单| I[追加到既有 Bill] + H -->|SKIP 跳过| J[不动] + H -->|REPLACE 替换| K[作废旧 Bill + 建新] + + G --> L[报告完成] + I --> L + J --> L + K --> L +``` + +## `GeneratePeriodicBillsAction` 参数 + +业务人员触发(`Bills` List 页 → 顶部 "批量生成周期账单" 按钮): + +| 参数 | 说明 | +|---|---| +| **期次(billing_period)** | 如 "2026 年 5 月",决定 start / end | +| **费用类型(fee_type_id)** | 物业费 / 停车费 / 有线电视费 / ... | +| **社区范围(community_id)** | 单社区 / 全平台 | +| **业户范围** | 单个业户 / 全社区业户 / 自定义清单 | +| **合并策略(merge_strategy)** | MERGE / SKIP / REPLACE | +| 备注 | 选填 | + +提交后系统扫描候选业户清单,逐户计算 + 建账。 + +## `BillingMergeStrategy` 枚举 + +> [!info] 实际枚举值看 `packages/prop-acc/src/Enums/BillingMergeStrategy.php` +> 可能值(推测): +> +> | 值 | 含义 | +> |---|---| +> | `SkipExisting` | 同业户同期次已有 Bill → 跳过(不重复生成)| +> | `Merge` | 追加到既有 Bill(同业户同期次的不同费用类型合一张)| +> | `Replace` | 作废旧 Bill + 建新(罕见,数据修复用)| +> +> 默认策略通常是 **`SkipExisting`**(最安全)。 + +## 三种策略对照 + +### 策略 1:SkipExisting(默认,推荐) + +业务人员 5 月 1 日点击"生成 5 月物业费"。系统: + +- 张阿姨已经有 5 月物业费 Bill → **跳过**(避免重复) +- 陈先生没有 5 月物业费 Bill → 新建 + +**适用**:正常月度操作,业务人员不确定是否之前已经生成过,跳过最安全。 + +### 策略 2:Merge(合单) + +业务人员同时为业户生成"5 月物业费 + 5 月电视费"。系统: + +- 找到张阿姨已有 5 月物业费 Bill(¥800) +- **追加电视费 ¥40 到同一张 Bill**(amount: 800 + 40 = 840) +- 业户收到**一张账单 ¥840**(明细两项) + +**适用**:多种费用类型一起发(减少业户收到的账单数,体验好)。 + +> [!warning] Merge 的局限 +> 合并后**单一 Bill 的金额 = 各费用类型总和**,但**关联的 sourceable** 怎么处理?Bill 表 sourceable 字段是 1:1。**当前实现可能**: +> - 不挂 sourceable(`sourceable_type=null`) +> - 或挂主费用类型的 source +> +> 具体看代码。Merge 策略实施细节复杂,生产环境**谨慎用**。 + +### 策略 3:Replace(替换) + +业务人员发现 5 月物业费金额算错了(RatePlan 改过),要全部重算: + +- 找到张阿姨已有的 5 月物业费 Bill +- **作废**(VoidBillAction,状态翻 Void) +- **重新生成**新 Bill(按新 RatePlan) + +**适用**:数据修复场景(罕见,需高权限 + 必填原因)。 + +> [!warning] Replace 的风险 +> 已付的 Bill 作废后,业户已经付的钱**怎么办**? +> +> - Bill 状态翻 Void +> - 已付的 CollectionOrderBill 关联保留(审计需要) +> - 但业户的钱 = 物业账面多收了 +> - **必须走退款**(建红字 CollectionOrder) +> +> Replace 策略**只对 Unpaid Bill 使用相对安全**;Paid Bill 慎用,需配套退款流程。 + +## 金额计算 + +周期账单金额怎么算?取决于 RatePlan 配置: + +| 算法 | 说明 | +|---|---| +| **固定金额** | 每户每月固定 ¥800(简单)| +| **按面积** | 房屋面积 × 单价(例如物业费 ¥3 / m² / 月)| +| **按车位** | 每个停车位固定金额(停车费)| +| **按设备** | 每台空调 ¥X / 月(中央空调费)| +| **阶梯** | 类似计量账单的阶梯(罕见)| + +具体看 `RatePlan` 的配置 + 业务计算逻辑。可能在 `PeriodicBillGenerationService`(若有)实现,或在 Action 内联。 + +## 生成的 Bill 字段 + +每张生成的 Bill: + +| 字段 | 值 | +|---|---| +| `bill_no` | 自动编号(`B-202605-501-001` 或类似)| +| `community_id` | 所属社区 | +| `asset_id` | 业户房屋 | +| `resident_id` | 业户 | +| `fee_type_id` | 费用类型 | +| `bill_type` | `Periodic`(枚举) | +| `amount` | 算出的金额 | +| `paid_amount` | 0(刚生成,未付)| +| `status` | `Unpaid` | +| `billing_period_start` | 期次开始(2026-05-01)| +| `billing_period_end` | 期次结束(2026-05-31)| +| `due_at` | 到期日(通常本期末 + 宽限期,如 2026-06-15)| +| `sourceable_*` | null(周期账单通常无 source)| + +## 业户视角 + +业户每月初(或月底,看物业策略)收到推送: + +> 张阿姨您好,您的 2026 年 5 月物业费 ¥800 已生成。请于 6 月 15 日前付清。 + +业户可选: + +- 现金 / 微信付 → 走 [[collect-payment-single]] +- 预存款抵 → 走 [[collect-via-prepaid-auto]](自动) +- 与其他账单一起付 → 走 [[collect-payment-batch]] +- 暂时不付 → 到期日后变逾期,走 [[exception-overdue-bills]] 催收 + +## 业务人员视角 + +### 月初执行 + +每月 1-3 日,业务人员王主管: + +1. 打开 BillsListPage → 顶部 "生成周期账单" +2. 选费用类型(物业费 / 停车费 / 等)+ 期次 + 策略 + 备注 +3. 提交 → 系统扫描 + 生成 → 报告"已生成 N 张账单,跳过 M 张" +4. 抽样核对几张 Bill 的金额 + 业户 + +### 异常处置 + +| 异常 | 处置 | +|---|---| +| 某户 RatePlan 配置缺失 | 跳过该户 + 在报告里标记 → 单独建 RatePlan + 单独生成 | +| 某户已有同期 Bill(被跳过)| 看是否需要 Merge / Replace | +| 全部失败(系统错)| 联系运维查日志 | + +## 自动化的可能 + +issue.md Q6 未列入"待补",但业务上可能需要: + +- **Scheduled job 月初自动跑** — 不需要业务人员手动触发 +- **跟 prepaid 自动抵扣 job 串联** — 账单生成 → 立即触发 [[../prepaid/auto-deduction-design|预存款自动抵]] → 业户感受"无感扣账单" + +当前**仍是手动触发**。 + +## 常见问题 + +> [!question] 同业户同期次同费用类型能有两张 Bill 吗? +> 业务上**不应该**(违反业务规则:一户一月一物业费)。系统层面看是否有 unique 约束: +> +> ```sql +> UNIQUE INDEX (community_id, resident_id, fee_type_id, billing_period_start) +> ``` +> +> 若有 → 数据库层挡住重复;若无 → 全靠 Action 的 SkipExisting 策略判断,理论上可能因并发产生重复。 + +> [!question] 业户搬走中途,本月物业费要不要全收? +> 看物业策略 + 合同约定: +> +> - **全月收**(简单,业户合同到月底) +> - **按天分摊**(按搬走前的天数算) +> - **不收**(罕见) +> +> 系统**当前简版**通常是全月收。按天分摊需要业务层算法支持。 + +> [!question] 周期账单生成后才发现 RatePlan 配错怎么办? +> 走 Replace 策略**只对 Unpaid 安全**。如果已付: +> +> - 看错的金额方向: +> - 收多了 → 退差额(走 [[void-paid-bill]] 类似流程) +> - 收少了 → 补开账单(新建一张 ¥差额 的临时账单) + +> [!question] 批量生成多久?300 户 5 秒? +> 单户 ~50-100ms(查 RatePlan + 查业户 + 建 Bill + 日志)。300 户**顺序执行** ~15-30 秒。可优化为并发(若业务量大)。 + +> [!question] 周期账单和计量账单可以一起生成吗? +> 不在同一个 Action(周期是 `GeneratePeriodicBillsAction`,计量是 [[../meter/bill-generation-pipeline|GenerateBillsFromMeterReadingsAction]])。**业务流程上**业务人员通常先做完抄表 + 计量账单生成 → 再触发周期账单生成。 + +## 相关文档 + +- [[bill-six-state-machine]] +- [[bill-types-and-sources]] +- [[bill-vs-collection-order]] +- [[create-periodic-property-fee]] +- [[create-meter-bill-auto]] +- [[../meter/bill-generation-pipeline]](类似的 Calculator + Service + Action 模式) +- [[../prepaid/auto-deduction-design]](账单生成后的下游消费) diff --git a/prop-acc/concepts/billing/smart-bulk-delete-design.md b/prop-acc/concepts/billing/smart-bulk-delete-design.md new file mode 100644 index 0000000..1a20a86 --- /dev/null +++ b/prop-acc/concepts/billing/smart-bulk-delete-design.md @@ -0,0 +1,359 @@ +--- +title: prop-acc · billing · 智能批量删除设计 +aliases: + - 智能批量删除 + - BulkDeleteBillsAction + - 三档分类 + - activitylog 审计 + - smart-bulk-delete-design +tags: + - 概念 + - prop-acc + - 账单 + - 数据治理 + - 审计 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: billing +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 智能批量删除设计 + +`BulkDeleteBillsAction` 是 prop-acc **最精巧的批量操作设计**:选中 N 张 Bill 后,系统**预检查 + 分类三档**(可删 / 可作废 / 跳过),Modal 显示统计 + 选模式 + 必填原因,最后**一条 activitylog** 记录全过程,`affected_ids` 串联具体哪些账单。 + +## 为什么要"智能" + +业务人员选了 100 张账单点"批量删除",其中可能: + +- 92 张是 Unpaid + 无付款 → 可物理删 +- 5 张是 Partial / Paid → 不能删(动了钱),但可作废 +- 3 张是 Suspended / 已 Void → 跳过 + +**简单批量删**(一刀切删全部)的灾难: + +- 5 张 Partial / Paid 的被删 → 业户付的钱凭空消失 + 审计断链 +- 业务人员可能根本不知道里面有 Partial / Paid(没逐张点开看) + +**智能批删**(本设计): + +1. **预检查** 每张 Bill 分类 +2. **Modal 显示**:`✅ 可删 92 ⚠️ 需作废 5 ❌ 跳过 3` +3. **业务人员选模式**:`仅删可删的` 或 `删可删 + 作废需作废的` +4. **必填原因**(审计要求) +5. **执行 + 一条 activitylog**:`affected_ids` 串联每张账单的处理结果 + +## 三档分类逻辑 + +```php +// 伪代码,来自 BulkDeleteBillsAction +foreach ($selectedBills as $bill) { + if ($bill->canBeDeleted()) { + $deletable[] = $bill; + } elseif ($bill->canBeVoided()) { + $voidable[] = $bill; + } else { + $blocked[] = $bill; // Paid / Void 已经 / 异常状态 + } +} +``` + +| 档 | 条件 | 处置 | +|---|---|---| +| **可删**(Deletable) | `canBeDeleted()` = Unpaid + 无付款 | 物理 delete | +| **可作废**(Voidable) | `canBeVoided()` = 非 Paid 非 Void | 翻状态 Void + 留 meta | +| **跳过**(Blocked) | Paid / Void / 其他异常 | 不动 | + +## Modal 设计 + +业务人员在 `BillsTable` 选 N 张 → 点 "批量删除" → 弹出: + +``` +批量删除账单 (选中 100 张) + +预检查统计: + ✅ 可删: 92 张 + ⚠️ 需作废: 5 张 + ❌ 跳过: 3 张(已付清 / 已作废 / 其他) + +请选择处理模式: + ⚪ 仅删除可删的(92 张物理删,5 张需作废 / 3 张跳过) + ⚫ 删可删 + 作废需作废的(92 删 + 5 作废,3 跳过) + +批量操作原因(必填,审计留痕): +[多行输入框] + +⚠️ 注意: + - 物理删除不可恢复(但 activitylog 保留 bill_no) + - 作废不可撤销 + - 跳过的账单不动 + +[取消] [确认执行] +``` + +## 业务 Action 实现(`src/Actions/Bills/BulkDeleteBillsAction`) + +```php +class BulkDeleteBillsAction +{ + public function handle( + iterable $bills, + BulkDeleteMode $mode, // OnlyDeletable / DeleteAndVoid + string $reason, + User $user, + ): BulkDeleteResult { + $deletable = []; + $voidable = []; + $blocked = []; + + // 第 1 步:分类 + foreach ($bills as $bill) { + if ($bill->canBeDeleted()) { + $deletable[] = $bill; + } elseif ($bill->canBeVoided()) { + $voidable[] = $bill; + } else { + $blocked[] = $bill; + } + } + + $deletedCount = 0; + $voidedCount = 0; + $affectedBillNos = []; + + // 第 2 步:执行 + DB::transaction(function () use (...) { + foreach ($deletable as $bill) { + $affectedBillNos[] = $bill->bill_no . ' [DELETED]'; + $bill->delete(); + $deletedCount++; + } + + if ($mode === BulkDeleteMode::DeleteAndVoid) { + foreach ($voidable as $bill) { + $affectedBillNos[] = $bill->bill_no . ' [VOIDED]'; + app(VoidBillAction::class)->handle($bill, $reason, $user); + $voidedCount++; + } + } + + foreach ($blocked as $bill) { + $affectedBillNos[] = $bill->bill_no . ' [SKIPPED]'; + } + + // 第 3 步:一条总体 activitylog + activity() + ->causedBy($user) + ->withProperties([ + 'mode' => $mode->value, + 'reason' => $reason, + 'total_selected' => count($bills), + 'deleted_count' => $deletedCount, + 'voided_count' => $voidedCount, + 'blocked_count' => count($blocked), + 'affected_bill_nos' => $affectedBillNos, + ]) + ->event('bulk_deleted') + ->log('批量删除账单'); + }); + + return new BulkDeleteResult( + deletedCount: $deletedCount, + voidedCount: $voidedCount, + blockedCount: count($blocked), + affectedBillNos: $affectedBillNos, + ); + } +} +``` + +## activitylog 设计 + +利用 `spatie/laravel-activitylog`(项目已装但本次首次启用)。 + +### 单条作废日志 + +`VoidBillAction` 触发,subject 是被作废的 Bill: + +| 字段 | 值 | +|---|---| +| `log_name` | `default` | +| `subject_type` | `Bill` | +| `subject_id` | 被作废 bill 的 ID | +| `event` | `voided` | +| `causer_id` | 操作员 ID | +| `properties` | `{reason, from_status, to_status, bill_no, amount}` | + +### 批量删除日志(本场景) + +`BulkDeleteBillsAction` 触发,**无 subject**(批量操作不绑单个对象): + +| 字段 | 值 | +|---|---| +| `log_name` | `default` | +| `subject_type` | null | +| `subject_id` | null | +| `event` | `bulk_deleted` | +| `causer_id` | 操作员 ID | +| `properties` | `{mode, reason, total_selected, deleted_count, voided_count, blocked_count, affected_bill_nos[]}` | + +`properties.affected_bill_nos` 是一个数组,每条形如 `"B-202605-501-001 [DELETED]"` / `"B-202605-502-003 [VOIDED]"`,审计可完整还原。 + +## 审计追溯 SQL + +```sql +-- 某员工某月的全部批量删除 +SELECT + id, + event, + causer_id, + properties->>'$.mode' AS mode, + properties->>'$.reason' AS reason, + properties->>'$.deleted_count' AS deleted, + properties->>'$.voided_count' AS voided, + properties->>'$.affected_bill_nos' AS affected, + created_at +FROM activity_log +WHERE causer_id = ? + AND event = 'bulk_deleted' + AND created_at BETWEEN '2026-05-01' AND '2026-05-31' +ORDER BY created_at DESC; +``` + +返回示例: + +| created_at | mode | reason | deleted | voided | affected (部分) | +|---|---|---|---|---|---| +| 2026-05-15 14:32 | DeleteAndVoid | "5 月物业费 RatePlan 配错,清理重生成" | 92 | 5 | ["B-202605-501-001 [DELETED]", ...] | + +## 完整业务流程 + +```mermaid +sequenceDiagram + participant 业务[业务人员] + participant Filament + participant BulkDelete[BulkDeleteBillsAction Filament UI] + participant Action[BulkDeleteBillsAction 业务层] + participant Bill[Bill 模型] + participant Activity[activitylog] + participant DB + + 业务->>Filament: 选 100 张 → 点"批量删除" + Filament->>BulkDelete: 显示 Modal + + BulkDelete->>BulkDelete: 预检查每张 Bill 分类 + BulkDelete->>Filament: Modal 显示 ✅92 ⚠️5 ❌3 + + 业务->>Filament: 选"删可删 + 作废需作废" + 填原因 + 提交 + Filament->>Action: handle(100 bills, mode=DeleteAndVoid, reason, user) + Action->>Bill: 第 1 步:再次分类(防 UI 缓存过期) + Bill-->>Action: 92 deletable / 5 voidable / 3 blocked + + Action->>DB: 开启事务 + + loop 92 deletable + Action->>Bill: delete() + Bill->>DB: 物理删 + end + + loop 5 voidable + Action->>Action: 调 VoidBillAction.handle(bill, reason, user) + Action->>Bill: 翻状态 Void + meta + Action->>Activity: log voided(单条) + end + + Action->>Activity: log bulk_deleted(总体 + affected_bill_nos) + Action->>DB: 提交事务 + + Action-->>Filament: 结果(92 删 / 5 作废 / 3 跳过) + Filament-->>业务: 通知"批量操作完成" +``` + +## 业务人员视角 + +完整流程: + +1. **筛选**:在 `BillsTable` 用过滤(按期次 / 状态 / 费用类型)选出要清理的批 +2. **选中**:Table 的勾选框 +3. **触发**:顶部 "批量删除" 按钮 +4. **看预检查**:Modal 显示 ✅ / ⚠️ / ❌ 三档统计 +5. **决策**:选"仅删可删的"或"删可删 + 作废需作废的" +6. **填原因**(必填):"5 月物业费配错,清理重生成" +7. **提交**:系统执行 + 通知 +8. **审计**:事后可查 activitylog 追溯 + +## 业户视角 + +业户感知: + +| 业户类型 | 感知 | +|---|---| +| 被物理删账单的业户(未付款)| **无感**(账单从未发出 / 未通知)| +| 被作废账单的业户(已付款)| 收到通知 + 看到 Void 状态 + 可能涉及退款 | +| 不在批量内的业户 | 无感 | + +## 权限设计 + +| 权限 | 谁该有 | +|---|---| +| `bill.delete` | 普通业务人员(单删)| +| `bill.bulkDelete` | 主管 / 财务总监(批删独立权限)| +| `bill.void` | 普通业务人员(作废)| + +`bulkDelete` 是**独立高敏权限**,因为: + +- 一次可能影响 100+ 张账单 +- 影响面大 → 限定高权限人员 +- 责任明确 → 出问题能追溯到这个人 + +## 常见问题 + +> [!question] 为什么不直接走"全部作废"? +> 物理删的"干净"价值见 [[delete-vs-void-dual-track]]。批量场景里,**绝大多数账单是 Unpaid 误建**,物理删一次清理干净;少数已动钱的走作废留痕,两全其美。 + +> [!question] Modal 预检查后实际执行时状态变了怎么办? +> Action **再次分类**(防 UI 缓存),实际执行用最新状态。例如: +> +> - Modal 显示 Bill #X 可删(Unpaid 无付款) +> - 业务人员看 Modal 时,另一人付了款 → Bill #X 变 Partial +> - 业务人员点提交 → Action 重新分类 → Bill #X 进 voidable 档 +> - 按当前模式处理(若选 "DeleteAndVoid",作废;若选 "OnlyDeletable",跳过) + +> [!question] activitylog 表会无限膨胀吗? +> 是的(每条单作废 + 每次批量都加一条)。需要**归档策略**: +> +> - 超 X 年的 activitylog 归档到冷存储 +> - 或拆表(按月分区) +> +> 当前未配置归档,需运维介入(若数据量大)。 + +> [!question] 业务人员误操作批删大量账单怎么办? +> activitylog 留有 `affected_bill_nos`,可以**反向恢复**(理论上): +> +> - 物理删的 Bill:无法恢复(数据真的没了),只能重新生成(走 [[create-periodic-property-fee]] 或 [[create-single-bill-manual]]) +> - 作废的 Bill:状态翻回 Unpaid 是不允许的(Void 是终态)→ 重新创建一张同信息的 Bill +> +> **预防**:必填原因 + 高权限 + Modal 显示统计 + 强调"不可恢复"。 + +> [!question] BulkDelete vs 直接逐条 Delete 100 次有什么区别? +> | 维度 | BulkDelete | 逐条 100 次 | +> |---|---|---| +> | activitylog | 1 条(汇总)| 100 条(详细)| +> | 业务人员效率 | 高(一次操作)| 低(100 次点击)| +> | 模式灵活性 | 选 DeleteAndVoid 一次处理混合状态 | 逐条决策 | +> | 审计可读性 | 中(看汇总 + affected)| 高(每条独立)| +> +> 当前设计**汇总一条 log + affected 数组** = 兼顾效率与可审计。 + +## 相关文档 + +- [[bill-six-state-machine]] +- [[delete-vs-void-dual-track]] +- [[delete-bill-unpaid]] +- [[void-paid-bill]] +- [[bulk-delete-batch-mistake]] +- [[audit-activitylog-trace]] diff --git a/prop-acc/maps/billing-knowledge-map.md b/prop-acc/maps/billing-knowledge-map.md new file mode 100644 index 0000000..b32dbe1 --- /dev/null +++ b/prop-acc/maps/billing-knowledge-map.md @@ -0,0 +1,144 @@ +--- +title: prop-acc · billing · 知识地图 +aliases: + - billing 知识地图 + - 账单知识地图 +tags: + - 规范 + - prop-acc + - 知识地图 + - 账单 +sub_feature: billing +audience: + - 业务人员 + - 财务 + - 抄表员 +status: 已发布 +last_review: 2026-05-26 +code_version: 2026-05-22 +--- + +# 账单(billing)知识地图 + +> 本子模块 = Bill + CollectionOrderBill(中间表)。覆盖物业收款的**应收应付侧**:账单生成、状态管理、收款关联、删除 / 作废 / 挂起 / 拆单全套。 +> +> billing 是 prop-acc **最复杂的子模块**,也是**收款流程的中枢**(各业务源 → Bill → CollectionOrder)。 + +## 这是什么? + +物业管理软件最核心的对象之一 —— **应收账款的记录**。从抄表 / 周期任务 / 手工建单产生,经历状态变化(Unpaid → Partial → Paid),最终通过 [CollectionOrder](../concepts/adhoc/collection-order-and-receipt.md) 完成收款。 + +## 与其他子模块的关系 + +| 关系 | 说明 | +|---|---| +| **上游**:meter → bill | 抄表 → 生成计量账单 | +| **上游**:周期任务 → bill | 月度物业费等批量生成 | +| **上游**:手工 → bill | 临时收费 | +| **下游**:bill → CollectionOrder | 收款时建 CO + Receipt | +| **侧链**:bill ← prepaid | 业户预存款抵账单(走 Bill consume,见 [prepaid 模块](prepaid-knowledge-map.md))| + +## 与其他子模块的核心差异 + +| 维度 | bill | 其他子模块 | +|---|---|---| +| 状态数 | **6**(最复杂)| deposit/prepaid 3,meter 2 | +| 删 / 作废 | **双轨制** | 只有 Close / Decommission | +| Policy 方法数 | **7** | deposit 12 / prepaid 9 / meter 5 | +| 审计 | **activitylog + meta** | meta JSON only | +| 批删 | **智能 Modal(3 档分类)** | 无 | +| 与 CollectionOrder 关系 | **多对多**(中间表)| 1:1(adhoc / deposit / prepaid) | + +## 核心概念(6 篇) + +| 文档 | 一句话 | +|---|---| +| [账单六状态机](../concepts/billing/bill-six-state-machine.md) | 6 状态(Unpaid / Partial / Paid / Suspended / Processing / Void),prop-acc 最复杂 | +| [账单类型与来源](../concepts/billing/bill-types-and-sources.md) | 周期 / 计量 / 临时 三类 + sourceable polymorphism | +| [Bill 与 CollectionOrder 关系](../concepts/billing/bill-vs-collection-order.md) | 应收 vs 已收,CollectionOrderBill 多对多 | +| [周期账单生成机制](../concepts/billing/periodic-bill-generation.md) | `GeneratePeriodicBillsAction` + `BillingMergeStrategy` 三种合并策略 | +| [删除 vs 作废双轨制](../concepts/billing/delete-vs-void-dual-track.md) | 物理删(Unpaid 无付款)vs 作废(留状态留审计)的设计哲学 | +| [智能批量删除设计](../concepts/billing/smart-bulk-delete-design.md) | 预检查三档分类 + 必填原因 + activitylog 完整审计 | + +## 场景手册(16 篇,**待补充 ✋**) + +> 🚧 概念骨架已就位,场景文档将在下一轮(轮 2)产出。预定结构如下。 + +### 📝 账单创建(3 篇) + +- 🚧 [月度物业费批量生成(`GeneratePeriodicBillsAction`)](../scenarios/billing/create-periodic-property-fee.md) +- 🚧 [抄表自动生成计量账单(走 meter pipeline)](../scenarios/billing/create-meter-bill-auto.md) +- 🚧 [手动建单(临时收费 / 调整账单)](../scenarios/billing/create-single-bill-manual.md) + +### 💰 收款(3 篇) + +- 🚧 [单张账单收款(`CollectPaymentAction`)](../scenarios/billing/collect-payment-single.md) +- 🚧 [同业户多账单批量收款(`BatchCollectPaymentAction`)](../scenarios/billing/collect-payment-batch.md) +- 🚧 [预存款抵扣自动收款(关联 prepaid)](../scenarios/billing/collect-via-prepaid-auto.md) + +### ✂️ 账单调整(3 篇) + +- 🚧 [拆账单(`SplitBillAction`,租户与房东分摊)](../scenarios/billing/split-bill.md) +- 🚧 [挂起账单(业户失联 / 纠纷)](../scenarios/billing/suspend-bill.md) +- 🚧 [恢复挂起的账单](../scenarios/billing/resume-bill.md) + +### 🗑️ 删除 / 作废(3 篇) + +- 🚧 [物理删除未付账单(误建立刻删)](../scenarios/billing/delete-bill-unpaid.md) +- 🚧 [作废已付账单(走作废 + 退款)](../scenarios/billing/void-paid-bill.md) +- 🚧 [批量误建,智能 Modal 三档清理](../scenarios/billing/bulk-delete-batch-mistake.md) + +### 🛡️ 异常 / 审计(4 篇) + +- 🚧 [部分付状态处理(Partial)](../scenarios/billing/exception-partial-payment.md) +- 🚧 [逾期账单清单 + 催收(`OverdueBillsListWidget`)](../scenarios/billing/exception-overdue-bills.md) +- 🚧 [月度账单生成 vs 收款对比(`MonthlyBillingVsCollectionChart`)](../scenarios/billing/audit-monthly-billing-vs-collection.md) +- 🚧 [activitylog 审计追溯](../scenarios/billing/audit-activitylog-trace.md) + +## 跨域引用 + +本子模块引用以下跨域共享概念: + +- [业户](../../cross/concepts/resident.md) — 账单收方 +- [房屋单元](../../cross/concepts/housing-unit.md) — `asset_id`,账单关联房屋 +- [组织结构](../../cross/concepts/org-hierarchy.md) — `community_id`,物业项目归属 + +## 跨子模块引用 + +- [adhoc · CollectionOrder 与 Receipt](../concepts/adhoc/collection-order-and-receipt.md) — 收款侧的核心对象 +- [meter · 账单生成的三层分层](../concepts/meter/bill-generation-pipeline.md) — 计量账单的生成器 +- [prepaid · Consume 走 CollectionType=Bill](../concepts/prepaid/consume-via-bill-collection-type.md) — 预存款抵账单的资金流 +- [meter · 表退役与读数锁定](../concepts/meter/decommission-and-locking.md) — 类似的"状态机+守护"对比 +- [deposit · 账户与流水](../concepts/deposit/deposit-account-vs-transaction.md) — 双对象模式对比 + +## 相关代码 + +- 模型:[`Bill.php`](../../../packages/prop-acc/src/Models/Bill.php)、[`CollectionOrderBill.php`](../../../packages/prop-acc/src/Models/CollectionOrderBill.php) +- 枚举:`BillStatus`(6 种)、`BillType`、`BillingMergeStrategy`、`FeeTypeBillType` +- Policy:`BillPolicy`(7 个方法:update / delete / deleteAny / void / collect / suspend / resume) +- 业务 Actions(src/Actions/Bills/): + - [`VoidBillAction`](../../../packages/prop-acc/src/Actions/Bills/VoidBillAction.php) + - [`SplitBillAction`](../../../packages/prop-acc/src/Actions/Bills/SplitBillAction.php) + - [`SuspendBillAction`](../../../packages/prop-acc/src/Actions/Bills/SuspendBillAction.php) + - [`ResumeBillAction`](../../../packages/prop-acc/src/Actions/Bills/ResumeBillAction.php) + - `BulkDeleteBillsAction`(智能批删) +- Filament Resource:[`packages/prop-acc/src/Filament/Resources/Bills/`](../../../packages/prop-acc/src/Filament/Resources/Bills/) +- Filament Actions(UI 入口):8 个(CollectPayment / BatchCollectPayment / GeneratePeriodicBills / Split / Suspend / Resume / Void / BulkDelete) +- Widgets:`BillingStatsOverviewWidget`、`MonthlyBillingVsCollectionChart`、`FeeTypeRevenueDistributionChart`、`OverdueBillsListWidget`、`MonthlyRevenueTrendChart` +- 业务设计决策:`packages/prop-acc/issue.md` 的 Q6 段(最详细的 issue 之一) + +## 相关文档 + +- [prop-acc 域知识地图](knowledge-map.md) +- [prop-acc 域首页](../index.md) +- [adhoc 子模块知识地图](adhoc-knowledge-map.md) +- [deposit 子模块知识地图](deposit-knowledge-map.md) +- [prepaid 子模块知识地图](prepaid-knowledge-map.md) +- [meter 子模块知识地图](meter-knowledge-map.md) +- [跨域协作地图](../../cross/maps/cross-domain-map.md) + +--- + +> [!info] 概念已完成,场景待补 +> 本轮(轮 1)产出:6 个概念 + 本子模块地图 + 域总图更新。 +> 下一轮(轮 2)产出:16 个场景文档,基于本知识地图骨架填充。 diff --git a/prop-acc/maps/knowledge-map.md b/prop-acc/maps/knowledge-map.md index da429a4..ba03d8a 100644 --- a/prop-acc/maps/knowledge-map.md +++ b/prop-acc/maps/knowledge-map.md @@ -23,7 +23,7 @@ last_review: 2026-05-25 | prepaid | 预存款 | 业户预存,自动抵扣月度账单 | [prepaid 知识地图](prepaid-knowledge-map.md) | ✅ 16 场景 + 6 概念 + 1 地图 = 23 篇 | | deposit | 保证金 | 装修押金等代管资金,完工后退还 | [deposit 知识地图](deposit-knowledge-map.md) | ✅ 18 场景 + 6 概念 + 1 地图 = 25 篇 | | meter | 计量表 | 水表/电表/燃气表,抄表生成账单 | [meter 知识地图](meter-knowledge-map.md) | ✅ 14 场景 + 6 概念 + 1 地图 = 21 篇 | -| billing | 账单 | 周期性账单 + 计量账单 | _待补_ | 🚧 | +| billing | 账单 | 周期性账单 + 计量账单 | [billing 知识地图](billing-knowledge-map.md) | 🟡 6 概念已完成,16 场景待补 | | payment-order | 收款订单 | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 | | receipt | 收据 | 成功收款后生成的凭证 | _待补_ | 🚧 |