vault backup: 2026-05-26 00:48:12
This commit is contained in:
320
prop-acc/concepts/billing/delete-vs-void-dual-track.md
Normal file
320
prop-acc/concepts/billing/delete-vs-void-dual-track.md
Normal 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 删除"对比)
|
||||
Reference in New Issue
Block a user