9.7 KiB
title, aliases, tags, audience, status, sub_feature, last_review, code_version
| title | aliases | tags | audience | status | sub_feature | last_review | code_version | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| prop-acc · billing · 删除 vs 作废双轨制 |
|
|
|
已发布 | billing | 2026-05-26 | 2026-05-22 |
删除 vs 作废双轨制
billing 模块对"账单消失"提供两条路径:物理删除(Delete,Unpaid + 无付款关联)和 作废(Void,留状态 + 留审计)。这是 prop-acc 其他子模块都没有的设计:deposit/prepaid/meter 都只支持 Close / Decommission 类的"终态保留"。
为什么要双轨制
业务方提了"想要批量删除功能 + 单张删除功能"。深入讨论后形成双轨:
| 场景 | 推荐路径 | 理由 |
|---|---|---|
| 误开账单立刻发现(还没付款) | 物理删除 | 干净,没钱被卷入,删了不留痕(activitylog 仍有) |
| 已生成 + 已收一部分钱(Partial) | 作废 | 钱已经卷入,删了等于消灭凭证;作废留状态 + 退款 |
| 已生成 + 已付清(Paid) | 作废(需走退款) | 同上 |
| 业户失联 / 长期不付 | 挂起(Suspend) | 暂停状态,不是终态;后续可恢复或作废 |
删除路径
守护
Bill::canBeDeleted():
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 守护
// 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():
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 分离):
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 守护
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 等下游凭证 | 不动(已经发出去的不撤销) |
业户可以看到账单"已作废",物业有完整审计链。
双轨决策树
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 第一轮)
- Bill 模型加 3 辅助方法(
hasAnyPayment/canBeDeleted/canBeVoided)- BillPolicy 补 7 方法(
update / delete / deleteAny / void / collect / suspend / resume)- 新增
VoidBillAction业务 Action + Filament UI- 新增
BulkDeleteBillsAction智能批删(详见 smart-bulk-delete-design)- 删除
DeleteBulkAction的visible(false)反模式 → 替换为真正的智能批删- EditAction / DeleteAction 三处加 visible 守护
- CollectPaymentAction authorize 改为
->authorize('collect')(取代跨模型 authorize)- 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 怎么查?
-- 某员工某月的全部批量删除 SELECT * FROM activity_log WHERE causer_id = ? AND event IN ('voided', 'bulk_deleted') AND created_at BETWEEN '2026-05-01' AND '2026-05-31';