vault backup: 2026-05-26 00:48:12

This commit is contained in:
Willie
2026-05-26 00:48:12 +08:00
parent 81c09219ea
commit 5934191115
6 changed files with 1082 additions and 5 deletions

View File

@@ -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<br/>需走专门退款流程<br/>未来扩展]
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 删除"对比)