11 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 · 智能批量删除设计 |
|
|
|
已发布 | billing | 2026-05-26 | 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(没逐张点开看)
智能批删(本设计):
- 预检查 每张 Bill 分类
- Modal 显示:
✅ 可删 92 ⚠️ 需作废 5 ❌ 跳过 3 - 业务人员选模式:
仅删可删的或删可删 + 作废需作废的 - 必填原因(审计要求)
- 执行 + 一条 activitylog:
affected_ids串联每张账单的处理结果
三档分类逻辑
// 伪代码,来自 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)
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
-- 某员工某月的全部批量删除
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]", ...] |
完整业务流程
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-->>业务: 通知"批量操作完成"
业务人员视角
完整流程:
- 筛选:在
BillsTable用过滤(按期次 / 状态 / 费用类型)选出要清理的批 - 选中:Table 的勾选框
- 触发:顶部 "批量删除" 按钮
- 看预检查:Modal 显示 ✅ / ⚠️ / ❌ 三档统计
- 决策:选"仅删可删的"或"删可删 + 作废需作废的"
- 填原因(必填):"5 月物业费配错,清理重生成"
- 提交:系统执行 + 通知
- 审计:事后可查 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 数组 = 兼顾效率与可审计。