--- 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 删除"对比)