Files
uniprop-manual/prop-acc/concepts/billing/smart-bulk-delete-design.md
2026-05-26 00:48:12 +08:00

360 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]]