Files
uniprop-manual/prop-acc/concepts/billing/smart-bulk-delete-design.md

360 lines
11 KiB
Markdown
Raw Normal View History

2026-05-26 00:48:12 +08:00
---
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]]