Files
uniprop-manual/prop-acc/concepts/billing/delete-vs-void-dual-track.md
2026-05-26 00:48:12 +08:00

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 作废双轨制
删除 vs 作废
双轨制
delete-vs-void-dual-track
VoidBillAction
canBeDeleted
canBeVoided
概念
prop-acc
账单
数据治理
业务人员
财务
架构师
已发布 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 第一轮)

  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. 删除 DeleteBulkActionvisible(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 怎么查?

-- 某员工某月的全部批量删除
SELECT * FROM activity_log
WHERE causer_id = ?
  AND event IN ('voided', 'bulk_deleted')
  AND created_at BETWEEN '2026-05-01' AND '2026-05-31';

相关文档