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

11 KiB
Raw Blame History

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 · 智能批量删除设计
智能批量删除
BulkDeleteBillsAction
三档分类
activitylog 审计
smart-bulk-delete-design
概念
prop-acc
账单
数据治理
审计
业务人员
财务
架构师
已发布 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(没逐张点开看)

智能批删(本设计):

  1. 预检查 每张 Bill 分类
  2. Modal 显示:✅ 可删 92 ⚠️ 需作废 5 ❌ 跳过 3
  3. 业务人员选模式:仅删可删的删可删 + 作废需作废的
  4. 必填原因(审计要求)
  5. 执行 + 一条 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-->>业务: 通知"批量操作完成"

业务人员视角

完整流程:

  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,可以反向恢复(理论上):

预防:必填原因 + 高权限 + Modal 显示统计 + 强调"不可恢复"。

[!question] BulkDelete vs 直接逐条 Delete 100 次有什么区别?

维度 BulkDelete 逐条 100 次
activitylog 1 条(汇总) 100 条(详细)
业务人员效率 高(一次操作) 低(100 次点击)
模式灵活性 选 DeleteAndVoid 一次处理混合状态 逐条决策
审计可读性 中(看汇总 + affected) 高(每条独立)

当前设计汇总一条 log + affected 数组 = 兼顾效率与可审计。

相关文档