vault backup: 2026-05-26 00:48:12
This commit is contained in:
320
prop-acc/concepts/billing/delete-vs-void-dual-track.md
Normal file
320
prop-acc/concepts/billing/delete-vs-void-dual-track.md
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: prop-acc · billing · 删除 vs 作废双轨制
|
||||
aliases:
|
||||
- 删除 vs 作废
|
||||
- 双轨制
|
||||
- delete-vs-void-dual-track
|
||||
- VoidBillAction
|
||||
- canBeDeleted
|
||||
- canBeVoided
|
||||
tags:
|
||||
- 概念
|
||||
- prop-acc
|
||||
- 账单
|
||||
- 数据治理
|
||||
audience:
|
||||
- 业务人员
|
||||
- 财务
|
||||
- 架构师
|
||||
status: 已发布
|
||||
sub_feature: billing
|
||||
last_review: 2026-05-26
|
||||
code_version: 2026-05-22
|
||||
---
|
||||
|
||||
# 删除 vs 作废双轨制
|
||||
|
||||
billing 模块对"账单消失"提供**两条路径**:**物理删除**(Delete,Unpaid + 无付款关联)和 **作废**(Void,留状态 + 留审计)。这是 prop-acc 其他子模块**都没有**的设计:deposit/prepaid/meter 都只支持 Close / Decommission 类的"终态保留"。
|
||||
|
||||
## 为什么要双轨制
|
||||
|
||||
业务方提了"想要批量删除功能 + 单张删除功能"。深入讨论后形成双轨:
|
||||
|
||||
| 场景 | 推荐路径 | 理由 |
|
||||
|---|---|---|
|
||||
| **误开账单立刻发现**(还没付款)| **物理删除** | 干净,没钱被卷入,删了不留痕(activitylog 仍有) |
|
||||
| **已生成 + 已收一部分钱**(Partial) | **作废** | 钱已经卷入,删了等于消灭凭证;作废留状态 + 退款 |
|
||||
| **已生成 + 已付清**(Paid)| **作废(需走退款)** | 同上 |
|
||||
| **业户失联 / 长期不付** | **挂起(Suspend)** | 暂停状态,不是终态;后续可恢复或作废 |
|
||||
|
||||
## 删除路径
|
||||
|
||||
### 守护
|
||||
|
||||
`Bill::canBeDeleted()`:
|
||||
|
||||
```php
|
||||
public function canBeDeleted(): bool
|
||||
{
|
||||
return $this->status === BillStatus::Unpaid
|
||||
&& ! $this->hasAnyPayment();
|
||||
}
|
||||
```
|
||||
|
||||
`Bill::hasAnyPayment()` 检查:
|
||||
|
||||
- `CollectionOrderBill`(走收款的关联)
|
||||
- `PrepaidTransaction`(走预存款抵的关联)
|
||||
|
||||
**Unpaid + 无任何付款关联** = 业户没付过任何钱 + 账单状态干净 → 安全删。
|
||||
|
||||
### 触发入口
|
||||
|
||||
| 入口 | UI |
|
||||
|---|---|
|
||||
| 单删 | `EditBill` 页面的 `DeleteAction`(`->visible(canBeDeleted)` + `->authorize('delete')`)|
|
||||
| 批删 | `BulkDeleteBillsAction`(智能 Modal,详见 [[smart-bulk-delete-design]])|
|
||||
|
||||
### Policy 守护
|
||||
|
||||
```php
|
||||
// BillPolicy
|
||||
public function delete(AuthUser $user, Model $record): bool
|
||||
{
|
||||
return $user->can('delete bills') && $record->canBeDeleted();
|
||||
}
|
||||
|
||||
public function deleteAny(AuthUser $user): bool
|
||||
{
|
||||
return $user->can('bulkDelete bills'); // 独立高敏权限
|
||||
}
|
||||
```
|
||||
|
||||
`deleteAny` 是**独立权限**(`bill.bulkDelete`),比单删更敏感(批删一次可能 100+ 张),需单独授权。
|
||||
|
||||
### 删除后留什么
|
||||
|
||||
| 留下 | 不留 |
|
||||
|---|---|
|
||||
| **activitylog** 记录(谁删了 / 什么时候 / 哪些 bill_no)| **Bill 数据库记录** |
|
||||
| 业户 / asset 等关联实体不动 | **关联的 CollectionOrderBill**(应该 0 个,因守护保证)|
|
||||
|
||||
activitylog 详见 [[smart-bulk-delete-design]]"activitylog 审计"段。
|
||||
|
||||
## 作废路径
|
||||
|
||||
### 守护
|
||||
|
||||
`Bill::canBeVoided()`:
|
||||
|
||||
```php
|
||||
public function canBeVoided(): bool
|
||||
{
|
||||
return $this->status !== BillStatus::Paid
|
||||
&& $this->status !== BillStatus::Void;
|
||||
}
|
||||
```
|
||||
|
||||
**非 Paid 非 Void** = 可作废。包括 Unpaid / Partial / Suspended / Processing。
|
||||
|
||||
> [!warning] Paid 的作废需走专门流程
|
||||
> `canBeVoided()` 对 Paid 返 false,因为单纯翻状态会让业户已付的钱"凭空消失"。Paid 账单作废需要**配套退款流程**(未来扩展,目前手工 / tinker 操作)。
|
||||
>
|
||||
> issue.md Q6 未具体规划"Paid 作废"业务流程,留作未来扩展。
|
||||
|
||||
### 触发入口
|
||||
|
||||
`VoidBillAction`(Filament UI)挂在 `EditBill` / `ViewBill` / Table 行。Modal 必填**作废原因**(`reason`):
|
||||
|
||||
```
|
||||
请填写作废原因(必填,审计留痕):
|
||||
[多行输入框]
|
||||
|
||||
[取消] [确认作废]
|
||||
```
|
||||
|
||||
### 业务 Action 实现
|
||||
|
||||
`src/Actions/Bills/VoidBillAction.php`(业务层,与 Filament UI 分离):
|
||||
|
||||
```php
|
||||
class VoidBillAction
|
||||
{
|
||||
public function handle(Bill $bill, string $reason, User $user): void
|
||||
{
|
||||
if (! $bill->canBeVoided()) {
|
||||
throw new RuntimeException("账单状态 {$bill->status->value} 不可作废");
|
||||
}
|
||||
|
||||
$bill->update([
|
||||
'status' => BillStatus::Void,
|
||||
'meta' => array_merge($bill->meta ?? [], [
|
||||
'voided_reason' => $reason,
|
||||
'voided_at' => now(),
|
||||
'voided_by' => $user->id,
|
||||
]),
|
||||
]);
|
||||
|
||||
activity()
|
||||
->performedOn($bill)
|
||||
->causedBy($user)
|
||||
->withProperties([
|
||||
'reason' => $reason,
|
||||
'from_status' => $bill->getOriginal('status'),
|
||||
'to_status' => BillStatus::Void->value,
|
||||
'bill_no' => $bill->bill_no,
|
||||
'amount' => $bill->amount,
|
||||
])
|
||||
->event('voided')
|
||||
->log('账单已作废');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Policy 守护
|
||||
|
||||
```php
|
||||
public function void(AuthUser $user, Bill $bill): bool
|
||||
{
|
||||
return $user->can('void bills') && $bill->canBeVoided();
|
||||
}
|
||||
```
|
||||
|
||||
### 作废后留什么
|
||||
|
||||
| 留下 | 改变 |
|
||||
|---|---|
|
||||
| **Bill 数据库记录**(只读)| `status = Void` |
|
||||
| **关联的 CollectionOrderBill**(若有)| 不动 |
|
||||
| **meta**:voided_reason / voided_at / voided_by | 新增 |
|
||||
| **activitylog**:event=voided | 新增 |
|
||||
| **Receipt 等下游凭证** | 不动(已经发出去的不撤销)|
|
||||
|
||||
业户可以看到账单"已作废",物业有完整审计链。
|
||||
|
||||
## 双轨决策树
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[要让账单"消失"] --> B{账单状态?}
|
||||
|
||||
B -->|Unpaid| C{有付款关联吗?}
|
||||
B -->|Partial / Suspended / Processing| D[走作废 Void]
|
||||
B -->|Paid| E[当前 canBeVoided=false<br/>需走专门退款流程<br/>未来扩展]
|
||||
B -->|Void| F[已经是 Void,无需重复]
|
||||
|
||||
C -->|无| G[物理删除 Delete]
|
||||
C -->|有| D
|
||||
```
|
||||
|
||||
## 业务人员视角
|
||||
|
||||
### 决策时
|
||||
|
||||
| 误开发现时间 | 推荐 |
|
||||
|---|---|
|
||||
| 几秒内(业户还在面前)| 物理删除(干净)|
|
||||
| 几小时(可能业户已付)| 先查是否已付:已付 → 作废 + 退;未付 → 删 |
|
||||
| 几天(已发账单)| 通常作废(留状态让业户知道) |
|
||||
| 几月(已查到)| 作废 + 走退款流程(若已付) |
|
||||
|
||||
### Modal 操作
|
||||
|
||||
**单删 Modal**(`EditBill` → DeleteAction):
|
||||
|
||||
```
|
||||
确认删除账单 #B-202605-501-001?
|
||||
|
||||
⚠️ 此账单状态为 Unpaid 且无付款关联,可物理删除。
|
||||
删除后无法恢复(但 activitylog 会保留记录)。
|
||||
|
||||
[取消] [确认删除]
|
||||
```
|
||||
|
||||
**作废 Modal**(`VoidBillAction`):
|
||||
|
||||
```
|
||||
作废账单 #B-202605-501-001
|
||||
|
||||
账单状态:Unpaid
|
||||
账单金额:¥800
|
||||
|
||||
请填写作废原因(必填):
|
||||
[多行输入]
|
||||
|
||||
[取消] [确认作废]
|
||||
```
|
||||
|
||||
## 业户视角
|
||||
|
||||
| 操作 | 业户感知 |
|
||||
|---|---|
|
||||
| 物理删除 | **无感**(账单从未真正"存在过",未通知业户) |
|
||||
| 作废 | 收到通知"您的账单 #XXX 已作废,理由 YYY" + 账单状态显示 Void |
|
||||
| Paid 作废(未来) | 收到通知 + 退款到账 + 红字凭证 |
|
||||
|
||||
## 与 prop-acc 其他模块的对比
|
||||
|
||||
| 模块 | 删 / 作废 / 退役 / 关账 |
|
||||
|---|---|
|
||||
| **deposit** | 无 delete UI(Policy 严格)+ ForceClose(终态)|
|
||||
| **prepaid** | 无 delete UI + CloseAction(终态)|
|
||||
| **meter** | 无 delete UI(仅退役 + 严格条件下删空表)+ Decommission |
|
||||
| **bill(本模块)** | **delete + void 双轨** + Suspend / Resume |
|
||||
|
||||
bill 的双轨是 prop-acc **最完整的"账单消失"设计**。其他模块"没有删,只有 终态"是因为它们的对象天然不该消失(账户、押金、表都是长期实体)。bill 的"账单"本质上是**可消失的事务记录**,所以双轨合理。
|
||||
|
||||
## 历史:issue.md Q6 的修复
|
||||
|
||||
> [!info] 修复前
|
||||
> `BillPolicy` 几乎是空壳(只有 `getPermissionPrefix`),`DeleteAction` 暴露在 EditBill 页 + `DeleteBulkAction` 暴露在 Table toolbar(`visible(false)` 假关闭),Unpaid + Paid + Void 全状态都能删 → 删 Paid Bill = 业户付的钱凭空消失 + 完全无审计痕迹。
|
||||
>
|
||||
> **风险等级**:严重(类似 deposit / prepaid 第二轮修复的"消灭法律证据"级别)。
|
||||
|
||||
> [!info] 修复后(issue.md Q6 第一轮)
|
||||
>
|
||||
> 1. Bill 模型加 3 辅助方法(`hasAnyPayment` / `canBeDeleted` / `canBeVoided`)
|
||||
> 2. BillPolicy 补 7 方法(`update / delete / deleteAny / void / collect / suspend / resume`)
|
||||
> 3. 新增 `VoidBillAction` 业务 Action + Filament UI
|
||||
> 4. 新增 `BulkDeleteBillsAction` 智能批删(详见 [[smart-bulk-delete-design]])
|
||||
> 5. 删除 `DeleteBulkAction` 的 `visible(false)` 反模式 → 替换为真正的智能批删
|
||||
> 6. EditAction / DeleteAction 三处加 visible 守护
|
||||
> 7. CollectPaymentAction authorize 改为 `->authorize('collect')`(取代跨模型 authorize)
|
||||
> 8. Plugin.php 扩权限位:`bill.bulkDelete` 独立高敏权限
|
||||
>
|
||||
> 全栈守护(UI / Policy / Action / Model)+ activitylog 审计。
|
||||
|
||||
## 常见问题
|
||||
|
||||
> [!question] 为什么不全部走作废,简化设计?
|
||||
> **物理删的"干净"是有价值的**:
|
||||
>
|
||||
> - 误开的账单不留痕,业务人员不慌
|
||||
> - 业户看不到"已作废"的诡异状态(从未存在过最自然)
|
||||
> - 数据库无 Void 状态垃圾堆积
|
||||
>
|
||||
> 但**只对没动钱的账单适用**。一旦动了钱,必须作废留痕,不能"假装从未发生"。
|
||||
|
||||
> [!question] 作废后能撤销吗?
|
||||
> 不能(Void 是终态,无 reactivate 路径)。如需"恢复":新建一张同信息的 Bill。
|
||||
|
||||
> [!question] hasAnyPayment 检查的"付款关联"具体是?
|
||||
> 看 `Bill::hasAnyPayment()` 实现。通常:
|
||||
>
|
||||
> - `collectionOrderBills()->exists()`(有任何分配记录)
|
||||
> - `prepaidTransactions()->where('related_bill_id', this->id)->exists()`(被 prepaid consume 过)
|
||||
>
|
||||
> 任意一个为真 → 有付款关联 → 不可物理删。
|
||||
|
||||
> [!question] 批量删的智能 Modal 怎么工作?
|
||||
> 详见专门概念 [[smart-bulk-delete-design]]。
|
||||
|
||||
> [!question] activitylog 怎么查?
|
||||
>
|
||||
> ```sql
|
||||
> -- 某员工某月的全部批量删除
|
||||
> SELECT * FROM activity_log
|
||||
> WHERE causer_id = ?
|
||||
> AND event IN ('voided', 'bulk_deleted')
|
||||
> AND created_at BETWEEN '2026-05-01' AND '2026-05-31';
|
||||
> ```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [[bill-six-state-machine]]
|
||||
- [[smart-bulk-delete-design]]
|
||||
- [[delete-bill-unpaid]]
|
||||
- [[void-paid-bill]]
|
||||
- [[bulk-delete-batch-mistake]]
|
||||
- [[suspend-bill]]
|
||||
- [[../meter/decommission-and-locking]](类似"保留 vs 删除"对比)
|
||||
254
prop-acc/concepts/billing/periodic-bill-generation.md
Normal file
254
prop-acc/concepts/billing/periodic-bill-generation.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: prop-acc · billing · 周期账单生成机制
|
||||
aliases:
|
||||
- 周期账单生成
|
||||
- 月度物业费生成
|
||||
- GeneratePeriodicBillsAction
|
||||
- BillingMergeStrategy
|
||||
- 合单策略
|
||||
tags:
|
||||
- 概念
|
||||
- prop-acc
|
||||
- 账单
|
||||
- 业务流程
|
||||
audience:
|
||||
- 业务人员
|
||||
- 财务
|
||||
- 架构师
|
||||
status: 已发布
|
||||
sub_feature: billing
|
||||
last_review: 2026-05-26
|
||||
code_version: 2026-05-22
|
||||
---
|
||||
|
||||
# 周期账单生成机制
|
||||
|
||||
物业费、停车费、电视费等**按周期(月 / 季 / 年)固定收**的账单,通过 **`GeneratePeriodicBillsAction`** 批量生成。配合 **`BillingMergeStrategy` 枚举**决定"同业户多个费用类型是否合并到一张账单"。
|
||||
|
||||
## 业务场景
|
||||
|
||||
每月 1 日,物业为本社区 **300 户业主**生成本月物业费账单(每户 ¥800 起步,按房屋面积浮动)。系统应:
|
||||
|
||||
- 自动算每户的金额(按 RatePlan + 房屋面积)
|
||||
- 批量建 Bill(300 张)
|
||||
- 各张 Bill 期次清晰(billing_period 5/1 - 5/31)
|
||||
- 不重复生成(本月已生成的不再生成)
|
||||
- 业务人员收到结果报告
|
||||
|
||||
## 批量生成流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[业务人员触发 GeneratePeriodicBillsAction] --> B[Modal 输入参数]
|
||||
B --> C[参数:期次 / 费用类型 / 社区范围 / 合并策略]
|
||||
C --> D[扫描候选业户清单]
|
||||
|
||||
D --> E[每户:计算应付金额]
|
||||
E --> F{已有本期 Bill?}
|
||||
|
||||
F -->|否| G[建新 Bill]
|
||||
F -->|是,根据策略| H{合并策略}
|
||||
|
||||
H -->|MERGE 合单| I[追加到既有 Bill]
|
||||
H -->|SKIP 跳过| J[不动]
|
||||
H -->|REPLACE 替换| K[作废旧 Bill + 建新]
|
||||
|
||||
G --> L[报告完成]
|
||||
I --> L
|
||||
J --> L
|
||||
K --> L
|
||||
```
|
||||
|
||||
## `GeneratePeriodicBillsAction` 参数
|
||||
|
||||
业务人员触发(`Bills` List 页 → 顶部 "批量生成周期账单" 按钮):
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| **期次(billing_period)** | 如 "2026 年 5 月",决定 start / end |
|
||||
| **费用类型(fee_type_id)** | 物业费 / 停车费 / 有线电视费 / ... |
|
||||
| **社区范围(community_id)** | 单社区 / 全平台 |
|
||||
| **业户范围** | 单个业户 / 全社区业户 / 自定义清单 |
|
||||
| **合并策略(merge_strategy)** | MERGE / SKIP / REPLACE |
|
||||
| 备注 | 选填 |
|
||||
|
||||
提交后系统扫描候选业户清单,逐户计算 + 建账。
|
||||
|
||||
## `BillingMergeStrategy` 枚举
|
||||
|
||||
> [!info] 实际枚举值看 `packages/prop-acc/src/Enums/BillingMergeStrategy.php`
|
||||
> 可能值(推测):
|
||||
>
|
||||
> | 值 | 含义 |
|
||||
> |---|---|
|
||||
> | `SkipExisting` | 同业户同期次已有 Bill → 跳过(不重复生成)|
|
||||
> | `Merge` | 追加到既有 Bill(同业户同期次的不同费用类型合一张)|
|
||||
> | `Replace` | 作废旧 Bill + 建新(罕见,数据修复用)|
|
||||
>
|
||||
> 默认策略通常是 **`SkipExisting`**(最安全)。
|
||||
|
||||
## 三种策略对照
|
||||
|
||||
### 策略 1:SkipExisting(默认,推荐)
|
||||
|
||||
业务人员 5 月 1 日点击"生成 5 月物业费"。系统:
|
||||
|
||||
- 张阿姨已经有 5 月物业费 Bill → **跳过**(避免重复)
|
||||
- 陈先生没有 5 月物业费 Bill → 新建
|
||||
|
||||
**适用**:正常月度操作,业务人员不确定是否之前已经生成过,跳过最安全。
|
||||
|
||||
### 策略 2:Merge(合单)
|
||||
|
||||
业务人员同时为业户生成"5 月物业费 + 5 月电视费"。系统:
|
||||
|
||||
- 找到张阿姨已有 5 月物业费 Bill(¥800)
|
||||
- **追加电视费 ¥40 到同一张 Bill**(amount: 800 + 40 = 840)
|
||||
- 业户收到**一张账单 ¥840**(明细两项)
|
||||
|
||||
**适用**:多种费用类型一起发(减少业户收到的账单数,体验好)。
|
||||
|
||||
> [!warning] Merge 的局限
|
||||
> 合并后**单一 Bill 的金额 = 各费用类型总和**,但**关联的 sourceable** 怎么处理?Bill 表 sourceable 字段是 1:1。**当前实现可能**:
|
||||
> - 不挂 sourceable(`sourceable_type=null`)
|
||||
> - 或挂主费用类型的 source
|
||||
>
|
||||
> 具体看代码。Merge 策略实施细节复杂,生产环境**谨慎用**。
|
||||
|
||||
### 策略 3:Replace(替换)
|
||||
|
||||
业务人员发现 5 月物业费金额算错了(RatePlan 改过),要全部重算:
|
||||
|
||||
- 找到张阿姨已有的 5 月物业费 Bill
|
||||
- **作废**(VoidBillAction,状态翻 Void)
|
||||
- **重新生成**新 Bill(按新 RatePlan)
|
||||
|
||||
**适用**:数据修复场景(罕见,需高权限 + 必填原因)。
|
||||
|
||||
> [!warning] Replace 的风险
|
||||
> 已付的 Bill 作废后,业户已经付的钱**怎么办**?
|
||||
>
|
||||
> - Bill 状态翻 Void
|
||||
> - 已付的 CollectionOrderBill 关联保留(审计需要)
|
||||
> - 但业户的钱 = 物业账面多收了
|
||||
> - **必须走退款**(建红字 CollectionOrder)
|
||||
>
|
||||
> Replace 策略**只对 Unpaid Bill 使用相对安全**;Paid Bill 慎用,需配套退款流程。
|
||||
|
||||
## 金额计算
|
||||
|
||||
周期账单金额怎么算?取决于 RatePlan 配置:
|
||||
|
||||
| 算法 | 说明 |
|
||||
|---|---|
|
||||
| **固定金额** | 每户每月固定 ¥800(简单)|
|
||||
| **按面积** | 房屋面积 × 单价(例如物业费 ¥3 / m² / 月)|
|
||||
| **按车位** | 每个停车位固定金额(停车费)|
|
||||
| **按设备** | 每台空调 ¥X / 月(中央空调费)|
|
||||
| **阶梯** | 类似计量账单的阶梯(罕见)|
|
||||
|
||||
具体看 `RatePlan` 的配置 + 业务计算逻辑。可能在 `PeriodicBillGenerationService`(若有)实现,或在 Action 内联。
|
||||
|
||||
## 生成的 Bill 字段
|
||||
|
||||
每张生成的 Bill:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| `bill_no` | 自动编号(`B-202605-501-001` 或类似)|
|
||||
| `community_id` | 所属社区 |
|
||||
| `asset_id` | 业户房屋 |
|
||||
| `resident_id` | 业户 |
|
||||
| `fee_type_id` | 费用类型 |
|
||||
| `bill_type` | `Periodic`(枚举) |
|
||||
| `amount` | 算出的金额 |
|
||||
| `paid_amount` | 0(刚生成,未付)|
|
||||
| `status` | `Unpaid` |
|
||||
| `billing_period_start` | 期次开始(2026-05-01)|
|
||||
| `billing_period_end` | 期次结束(2026-05-31)|
|
||||
| `due_at` | 到期日(通常本期末 + 宽限期,如 2026-06-15)|
|
||||
| `sourceable_*` | null(周期账单通常无 source)|
|
||||
|
||||
## 业户视角
|
||||
|
||||
业户每月初(或月底,看物业策略)收到推送:
|
||||
|
||||
> 张阿姨您好,您的 2026 年 5 月物业费 ¥800 已生成。请于 6 月 15 日前付清。
|
||||
|
||||
业户可选:
|
||||
|
||||
- 现金 / 微信付 → 走 [[collect-payment-single]]
|
||||
- 预存款抵 → 走 [[collect-via-prepaid-auto]](自动)
|
||||
- 与其他账单一起付 → 走 [[collect-payment-batch]]
|
||||
- 暂时不付 → 到期日后变逾期,走 [[exception-overdue-bills]] 催收
|
||||
|
||||
## 业务人员视角
|
||||
|
||||
### 月初执行
|
||||
|
||||
每月 1-3 日,业务人员王主管:
|
||||
|
||||
1. 打开 BillsListPage → 顶部 "生成周期账单"
|
||||
2. 选费用类型(物业费 / 停车费 / 等)+ 期次 + 策略 + 备注
|
||||
3. 提交 → 系统扫描 + 生成 → 报告"已生成 N 张账单,跳过 M 张"
|
||||
4. 抽样核对几张 Bill 的金额 + 业户
|
||||
|
||||
### 异常处置
|
||||
|
||||
| 异常 | 处置 |
|
||||
|---|---|
|
||||
| 某户 RatePlan 配置缺失 | 跳过该户 + 在报告里标记 → 单独建 RatePlan + 单独生成 |
|
||||
| 某户已有同期 Bill(被跳过)| 看是否需要 Merge / Replace |
|
||||
| 全部失败(系统错)| 联系运维查日志 |
|
||||
|
||||
## 自动化的可能
|
||||
|
||||
issue.md Q6 未列入"待补",但业务上可能需要:
|
||||
|
||||
- **Scheduled job 月初自动跑** — 不需要业务人员手动触发
|
||||
- **跟 prepaid 自动抵扣 job 串联** — 账单生成 → 立即触发 [[../prepaid/auto-deduction-design|预存款自动抵]] → 业户感受"无感扣账单"
|
||||
|
||||
当前**仍是手动触发**。
|
||||
|
||||
## 常见问题
|
||||
|
||||
> [!question] 同业户同期次同费用类型能有两张 Bill 吗?
|
||||
> 业务上**不应该**(违反业务规则:一户一月一物业费)。系统层面看是否有 unique 约束:
|
||||
>
|
||||
> ```sql
|
||||
> UNIQUE INDEX (community_id, resident_id, fee_type_id, billing_period_start)
|
||||
> ```
|
||||
>
|
||||
> 若有 → 数据库层挡住重复;若无 → 全靠 Action 的 SkipExisting 策略判断,理论上可能因并发产生重复。
|
||||
|
||||
> [!question] 业户搬走中途,本月物业费要不要全收?
|
||||
> 看物业策略 + 合同约定:
|
||||
>
|
||||
> - **全月收**(简单,业户合同到月底)
|
||||
> - **按天分摊**(按搬走前的天数算)
|
||||
> - **不收**(罕见)
|
||||
>
|
||||
> 系统**当前简版**通常是全月收。按天分摊需要业务层算法支持。
|
||||
|
||||
> [!question] 周期账单生成后才发现 RatePlan 配错怎么办?
|
||||
> 走 Replace 策略**只对 Unpaid 安全**。如果已付:
|
||||
>
|
||||
> - 看错的金额方向:
|
||||
> - 收多了 → 退差额(走 [[void-paid-bill]] 类似流程)
|
||||
> - 收少了 → 补开账单(新建一张 ¥差额 的临时账单)
|
||||
|
||||
> [!question] 批量生成多久?300 户 5 秒?
|
||||
> 单户 ~50-100ms(查 RatePlan + 查业户 + 建 Bill + 日志)。300 户**顺序执行** ~15-30 秒。可优化为并发(若业务量大)。
|
||||
|
||||
> [!question] 周期账单和计量账单可以一起生成吗?
|
||||
> 不在同一个 Action(周期是 `GeneratePeriodicBillsAction`,计量是 [[../meter/bill-generation-pipeline|GenerateBillsFromMeterReadingsAction]])。**业务流程上**业务人员通常先做完抄表 + 计量账单生成 → 再触发周期账单生成。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [[bill-six-state-machine]]
|
||||
- [[bill-types-and-sources]]
|
||||
- [[bill-vs-collection-order]]
|
||||
- [[create-periodic-property-fee]]
|
||||
- [[create-meter-bill-auto]]
|
||||
- [[../meter/bill-generation-pipeline]](类似的 Calculator + Service + Action 模式)
|
||||
- [[../prepaid/auto-deduction-design]](账单生成后的下游消费)
|
||||
359
prop-acc/concepts/billing/smart-bulk-delete-design.md
Normal file
359
prop-acc/concepts/billing/smart-bulk-delete-design.md
Normal file
@@ -0,0 +1,359 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user