360 lines
11 KiB
Markdown
360 lines
11 KiB
Markdown
|
|
---
|
|||
|
|
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]]
|