--- title: prop-acc · billing · 智能批量删除设计 aliases: - 智能批量删除 - BulkDeleteBillsAction - 三档分类 - activitylog 审计 - smart-bulk-delete-design tags: - 概念 - prop-acc - 账单 - 数据治理 - 审计 audience: - 业务人员 - 财务 - 架构师 status: 已发布 sub_feature: billing last_review: 2026-05-26 code_version: 2026-05-22 --- # 智能批量删除设计 `BulkDeleteBillsAction` 是 prop-acc **最精巧的批量操作设计**:选中 N 张 Bill 后,系统**预检查 + 分类三档**(可删 / 可作废 / 跳过),Modal 显示统计 + 选模式 + 必填原因,最后**一条 activitylog** 记录全过程,`affected_ids` 串联具体哪些账单。 ## 为什么要"智能" 业务人员选了 100 张账单点"批量删除",其中可能: - 92 张是 Unpaid + 无付款 → 可物理删 - 5 张是 Partial / Paid → 不能删(动了钱),但可作废 - 3 张是 Suspended / 已 Void → 跳过 **简单批量删**(一刀切删全部)的灾难: - 5 张 Partial / Paid 的被删 → 业户付的钱凭空消失 + 审计断链 - 业务人员可能根本不知道里面有 Partial / Paid(没逐张点开看) **智能批删**(本设计): 1. **预检查** 每张 Bill 分类 2. **Modal 显示**:`✅ 可删 92 ⚠️ 需作废 5 ❌ 跳过 3` 3. **业务人员选模式**:`仅删可删的` 或 `删可删 + 作废需作废的` 4. **必填原因**(审计要求) 5. **执行 + 一条 activitylog**:`affected_ids` 串联每张账单的处理结果 ## 三档分类逻辑 ```php // 伪代码,来自 BulkDeleteBillsAction foreach ($selectedBills as $bill) { if ($bill->canBeDeleted()) { $deletable[] = $bill; } elseif ($bill->canBeVoided()) { $voidable[] = $bill; } else { $blocked[] = $bill; // Paid / Void 已经 / 异常状态 } } ``` | 档 | 条件 | 处置 | |---|---|---| | **可删**(Deletable) | `canBeDeleted()` = Unpaid + 无付款 | 物理 delete | | **可作废**(Voidable) | `canBeVoided()` = 非 Paid 非 Void | 翻状态 Void + 留 meta | | **跳过**(Blocked) | Paid / Void / 其他异常 | 不动 | ## Modal 设计 业务人员在 `BillsTable` 选 N 张 → 点 "批量删除" → 弹出: ``` 批量删除账单 (选中 100 张) 预检查统计: ✅ 可删: 92 张 ⚠️ 需作废: 5 张 ❌ 跳过: 3 张(已付清 / 已作废 / 其他) 请选择处理模式: ⚪ 仅删除可删的(92 张物理删,5 张需作废 / 3 张跳过) ⚫ 删可删 + 作废需作废的(92 删 + 5 作废,3 跳过) 批量操作原因(必填,审计留痕): [多行输入框] ⚠️ 注意: - 物理删除不可恢复(但 activitylog 保留 bill_no) - 作废不可撤销 - 跳过的账单不动 [取消] [确认执行] ``` ## 业务 Action 实现(`src/Actions/Bills/BulkDeleteBillsAction`) ```php class BulkDeleteBillsAction { public function handle( iterable $bills, BulkDeleteMode $mode, // OnlyDeletable / DeleteAndVoid string $reason, User $user, ): BulkDeleteResult { $deletable = []; $voidable = []; $blocked = []; // 第 1 步:分类 foreach ($bills as $bill) { if ($bill->canBeDeleted()) { $deletable[] = $bill; } elseif ($bill->canBeVoided()) { $voidable[] = $bill; } else { $blocked[] = $bill; } } $deletedCount = 0; $voidedCount = 0; $affectedBillNos = []; // 第 2 步:执行 DB::transaction(function () use (...) { foreach ($deletable as $bill) { $affectedBillNos[] = $bill->bill_no . ' [DELETED]'; $bill->delete(); $deletedCount++; } if ($mode === BulkDeleteMode::DeleteAndVoid) { foreach ($voidable as $bill) { $affectedBillNos[] = $bill->bill_no . ' [VOIDED]'; app(VoidBillAction::class)->handle($bill, $reason, $user); $voidedCount++; } } foreach ($blocked as $bill) { $affectedBillNos[] = $bill->bill_no . ' [SKIPPED]'; } // 第 3 步:一条总体 activitylog activity() ->causedBy($user) ->withProperties([ 'mode' => $mode->value, 'reason' => $reason, 'total_selected' => count($bills), 'deleted_count' => $deletedCount, 'voided_count' => $voidedCount, 'blocked_count' => count($blocked), 'affected_bill_nos' => $affectedBillNos, ]) ->event('bulk_deleted') ->log('批量删除账单'); }); return new BulkDeleteResult( deletedCount: $deletedCount, voidedCount: $voidedCount, blockedCount: count($blocked), affectedBillNos: $affectedBillNos, ); } } ``` ## activitylog 设计 利用 `spatie/laravel-activitylog`(项目已装但本次首次启用)。 ### 单条作废日志 `VoidBillAction` 触发,subject 是被作废的 Bill: | 字段 | 值 | |---|---| | `log_name` | `default` | | `subject_type` | `Bill` | | `subject_id` | 被作废 bill 的 ID | | `event` | `voided` | | `causer_id` | 操作员 ID | | `properties` | `{reason, from_status, to_status, bill_no, amount}` | ### 批量删除日志(本场景) `BulkDeleteBillsAction` 触发,**无 subject**(批量操作不绑单个对象): | 字段 | 值 | |---|---| | `log_name` | `default` | | `subject_type` | null | | `subject_id` | null | | `event` | `bulk_deleted` | | `causer_id` | 操作员 ID | | `properties` | `{mode, reason, total_selected, deleted_count, voided_count, blocked_count, affected_bill_nos[]}` | `properties.affected_bill_nos` 是一个数组,每条形如 `"B-202605-501-001 [DELETED]"` / `"B-202605-502-003 [VOIDED]"`,审计可完整还原。 ## 审计追溯 SQL ```sql -- 某员工某月的全部批量删除 SELECT id, event, causer_id, properties->>'$.mode' AS mode, properties->>'$.reason' AS reason, properties->>'$.deleted_count' AS deleted, properties->>'$.voided_count' AS voided, properties->>'$.affected_bill_nos' AS affected, created_at FROM activity_log WHERE causer_id = ? AND event = 'bulk_deleted' AND created_at BETWEEN '2026-05-01' AND '2026-05-31' ORDER BY created_at DESC; ``` 返回示例: | created_at | mode | reason | deleted | voided | affected (部分) | |---|---|---|---|---|---| | 2026-05-15 14:32 | DeleteAndVoid | "5 月物业费 RatePlan 配错,清理重生成" | 92 | 5 | ["B-202605-501-001 [DELETED]", ...] | ## 完整业务流程 ```mermaid sequenceDiagram participant 业务[业务人员] participant Filament participant BulkDelete[BulkDeleteBillsAction Filament UI] participant Action[BulkDeleteBillsAction 业务层] participant Bill[Bill 模型] participant Activity[activitylog] participant DB 业务->>Filament: 选 100 张 → 点"批量删除" Filament->>BulkDelete: 显示 Modal BulkDelete->>BulkDelete: 预检查每张 Bill 分类 BulkDelete->>Filament: Modal 显示 ✅92 ⚠️5 ❌3 业务->>Filament: 选"删可删 + 作废需作废" + 填原因 + 提交 Filament->>Action: handle(100 bills, mode=DeleteAndVoid, reason, user) Action->>Bill: 第 1 步:再次分类(防 UI 缓存过期) Bill-->>Action: 92 deletable / 5 voidable / 3 blocked Action->>DB: 开启事务 loop 92 deletable Action->>Bill: delete() Bill->>DB: 物理删 end loop 5 voidable Action->>Action: 调 VoidBillAction.handle(bill, reason, user) Action->>Bill: 翻状态 Void + meta Action->>Activity: log voided(单条) end Action->>Activity: log bulk_deleted(总体 + affected_bill_nos) Action->>DB: 提交事务 Action-->>Filament: 结果(92 删 / 5 作废 / 3 跳过) Filament-->>业务: 通知"批量操作完成" ``` ## 业务人员视角 完整流程: 1. **筛选**:在 `BillsTable` 用过滤(按期次 / 状态 / 费用类型)选出要清理的批 2. **选中**:Table 的勾选框 3. **触发**:顶部 "批量删除" 按钮 4. **看预检查**:Modal 显示 ✅ / ⚠️ / ❌ 三档统计 5. **决策**:选"仅删可删的"或"删可删 + 作废需作废的" 6. **填原因**(必填):"5 月物业费配错,清理重生成" 7. **提交**:系统执行 + 通知 8. **审计**:事后可查 activitylog 追溯 ## 业户视角 业户感知: | 业户类型 | 感知 | |---|---| | 被物理删账单的业户(未付款)| **无感**(账单从未发出 / 未通知)| | 被作废账单的业户(已付款)| 收到通知 + 看到 Void 状态 + 可能涉及退款 | | 不在批量内的业户 | 无感 | ## 权限设计 | 权限 | 谁该有 | |---|---| | `bill.delete` | 普通业务人员(单删)| | `bill.bulkDelete` | 主管 / 财务总监(批删独立权限)| | `bill.void` | 普通业务人员(作废)| `bulkDelete` 是**独立高敏权限**,因为: - 一次可能影响 100+ 张账单 - 影响面大 → 限定高权限人员 - 责任明确 → 出问题能追溯到这个人 ## 常见问题 > [!question] 为什么不直接走"全部作废"? > 物理删的"干净"价值见 [[delete-vs-void-dual-track]]。批量场景里,**绝大多数账单是 Unpaid 误建**,物理删一次清理干净;少数已动钱的走作废留痕,两全其美。 > [!question] Modal 预检查后实际执行时状态变了怎么办? > Action **再次分类**(防 UI 缓存),实际执行用最新状态。例如: > > - Modal 显示 Bill #X 可删(Unpaid 无付款) > - 业务人员看 Modal 时,另一人付了款 → Bill #X 变 Partial > - 业务人员点提交 → Action 重新分类 → Bill #X 进 voidable 档 > - 按当前模式处理(若选 "DeleteAndVoid",作废;若选 "OnlyDeletable",跳过) > [!question] activitylog 表会无限膨胀吗? > 是的(每条单作废 + 每次批量都加一条)。需要**归档策略**: > > - 超 X 年的 activitylog 归档到冷存储 > - 或拆表(按月分区) > > 当前未配置归档,需运维介入(若数据量大)。 > [!question] 业务人员误操作批删大量账单怎么办? > activitylog 留有 `affected_bill_nos`,可以**反向恢复**(理论上): > > - 物理删的 Bill:无法恢复(数据真的没了),只能重新生成(走 [[create-periodic-property-fee]] 或 [[create-single-bill-manual]]) > - 作废的 Bill:状态翻回 Unpaid 是不允许的(Void 是终态)→ 重新创建一张同信息的 Bill > > **预防**:必填原因 + 高权限 + Modal 显示统计 + 强调"不可恢复"。 > [!question] BulkDelete vs 直接逐条 Delete 100 次有什么区别? > | 维度 | BulkDelete | 逐条 100 次 | > |---|---|---| > | activitylog | 1 条(汇总)| 100 条(详细)| > | 业务人员效率 | 高(一次操作)| 低(100 次点击)| > | 模式灵活性 | 选 DeleteAndVoid 一次处理混合状态 | 逐条决策 | > | 审计可读性 | 中(看汇总 + affected)| 高(每条独立)| > > 当前设计**汇总一条 log + affected 数组** = 兼顾效率与可审计。 ## 相关文档 - [[bill-six-state-machine]] - [[delete-vs-void-dual-track]] - [[delete-bill-unpaid]] - [[void-paid-bill]] - [[bulk-delete-batch-mistake]] - [[audit-activitylog-trace]]