vault backup: 2026-05-26 00:48:12

This commit is contained in:
Willie
2026-05-26 00:48:12 +08:00
parent 81c09219ea
commit 5934191115
6 changed files with 1082 additions and 5 deletions

View File

@@ -197,6 +197,10 @@
}, },
"active": "849c5ff8936a2b67", "active": "849c5ff8936a2b67",
"lastOpenFiles": [ "lastOpenFiles": [
"prop-acc/maps/billing-knowledge-map.md",
"prop-acc/concepts/billing/smart-bulk-delete-design.md",
"prop-acc/concepts/billing/delete-vs-void-dual-track.md",
"prop-acc/concepts/billing/periodic-bill-generation.md",
"prop-acc/concepts/billing/bill-vs-collection-order.md", "prop-acc/concepts/billing/bill-vs-collection-order.md",
"prop-acc/concepts/billing/bill-types-and-sources.md", "prop-acc/concepts/billing/bill-types-and-sources.md",
"prop-acc/concepts/billing/bill-six-state-machine.md", "prop-acc/concepts/billing/bill-six-state-machine.md",
@@ -221,11 +225,7 @@
"prop-acc/concepts/meter/reading-source-and-photo-proof.md", "prop-acc/concepts/meter/reading-source-and-photo-proof.md",
"prop-acc/concepts/meter/bill-generation-pipeline.md", "prop-acc/concepts/meter/bill-generation-pipeline.md",
"prop-acc/concepts/meter/multiplier-and-tiered-pricing.md", "prop-acc/concepts/meter/multiplier-and-tiered-pricing.md",
"prop-acc/concepts/meter/replacement-chain.md",
"prop-acc/concepts/meter/meter-vs-meter-reading.md",
"prop-acc/concepts/meter", "prop-acc/concepts/meter",
"prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md",
"prop-acc/scenarios/prepaid/exception-refund-on-frozen.md",
"prop-acc/scenarios/prepaid", "prop-acc/scenarios/prepaid",
"prop-acc/concepts/prepaid", "prop-acc/concepts/prepaid",
"prop-acc/scenarios/deposit", "prop-acc/scenarios/deposit",

View 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 删除"对比)

View 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]](账单生成后的下游消费)

View 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]]

View File

@@ -0,0 +1,144 @@
---
title: prop-acc · billing · 知识地图
aliases:
- billing 知识地图
- 账单知识地图
tags:
- 规范
- prop-acc
- 知识地图
- 账单
sub_feature: billing
audience:
- 业务人员
- 财务
- 抄表员
status: 已发布
last_review: 2026-05-26
code_version: 2026-05-22
---
# 账单(billing)知识地图
> 本子模块 = Bill + CollectionOrderBill(中间表)。覆盖物业收款的**应收应付侧**:账单生成、状态管理、收款关联、删除 / 作废 / 挂起 / 拆单全套。
>
> billing 是 prop-acc **最复杂的子模块**,也是**收款流程的中枢**(各业务源 → Bill → CollectionOrder)。
## 这是什么?
物业管理软件最核心的对象之一 —— **应收账款的记录**。从抄表 / 周期任务 / 手工建单产生,经历状态变化(Unpaid → Partial → Paid),最终通过 [CollectionOrder](../concepts/adhoc/collection-order-and-receipt.md) 完成收款。
## 与其他子模块的关系
| 关系 | 说明 |
|---|---|
| **上游**:meter → bill | 抄表 → 生成计量账单 |
| **上游**:周期任务 → bill | 月度物业费等批量生成 |
| **上游**:手工 → bill | 临时收费 |
| **下游**:bill → CollectionOrder | 收款时建 CO + Receipt |
| **侧链**:bill ← prepaid | 业户预存款抵账单(走 Bill consume,见 [prepaid 模块](prepaid-knowledge-map.md))|
## 与其他子模块的核心差异
| 维度 | bill | 其他子模块 |
|---|---|---|
| 状态数 | **6**(最复杂)| deposit/prepaid 3,meter 2 |
| 删 / 作废 | **双轨制** | 只有 Close / Decommission |
| Policy 方法数 | **7** | deposit 12 / prepaid 9 / meter 5 |
| 审计 | **activitylog + meta** | meta JSON only |
| 批删 | **智能 Modal(3 档分类)** | 无 |
| 与 CollectionOrder 关系 | **多对多**(中间表)| 1:1(adhoc / deposit / prepaid) |
## 核心概念(6 篇)
| 文档 | 一句话 |
|---|---|
| [账单六状态机](../concepts/billing/bill-six-state-machine.md) | 6 状态(Unpaid / Partial / Paid / Suspended / Processing / Void),prop-acc 最复杂 |
| [账单类型与来源](../concepts/billing/bill-types-and-sources.md) | 周期 / 计量 / 临时 三类 + sourceable polymorphism |
| [Bill 与 CollectionOrder 关系](../concepts/billing/bill-vs-collection-order.md) | 应收 vs 已收,CollectionOrderBill 多对多 |
| [周期账单生成机制](../concepts/billing/periodic-bill-generation.md) | `GeneratePeriodicBillsAction` + `BillingMergeStrategy` 三种合并策略 |
| [删除 vs 作废双轨制](../concepts/billing/delete-vs-void-dual-track.md) | 物理删(Unpaid 无付款)vs 作废(留状态留审计)的设计哲学 |
| [智能批量删除设计](../concepts/billing/smart-bulk-delete-design.md) | 预检查三档分类 + 必填原因 + activitylog 完整审计 |
## 场景手册(16 篇,**待补充 ✋**)
> 🚧 概念骨架已就位,场景文档将在下一轮(轮 2)产出。预定结构如下。
### 📝 账单创建(3 篇)
- 🚧 [月度物业费批量生成(`GeneratePeriodicBillsAction`)](../scenarios/billing/create-periodic-property-fee.md)
- 🚧 [抄表自动生成计量账单(走 meter pipeline)](../scenarios/billing/create-meter-bill-auto.md)
- 🚧 [手动建单(临时收费 / 调整账单)](../scenarios/billing/create-single-bill-manual.md)
### 💰 收款(3 篇)
- 🚧 [单张账单收款(`CollectPaymentAction`)](../scenarios/billing/collect-payment-single.md)
- 🚧 [同业户多账单批量收款(`BatchCollectPaymentAction`)](../scenarios/billing/collect-payment-batch.md)
- 🚧 [预存款抵扣自动收款(关联 prepaid)](../scenarios/billing/collect-via-prepaid-auto.md)
### ✂️ 账单调整(3 篇)
- 🚧 [拆账单(`SplitBillAction`,租户与房东分摊)](../scenarios/billing/split-bill.md)
- 🚧 [挂起账单(业户失联 / 纠纷)](../scenarios/billing/suspend-bill.md)
- 🚧 [恢复挂起的账单](../scenarios/billing/resume-bill.md)
### 🗑️ 删除 / 作废(3 篇)
- 🚧 [物理删除未付账单(误建立刻删)](../scenarios/billing/delete-bill-unpaid.md)
- 🚧 [作废已付账单(走作废 + 退款)](../scenarios/billing/void-paid-bill.md)
- 🚧 [批量误建,智能 Modal 三档清理](../scenarios/billing/bulk-delete-batch-mistake.md)
### 🛡️ 异常 / 审计(4 篇)
- 🚧 [部分付状态处理(Partial)](../scenarios/billing/exception-partial-payment.md)
- 🚧 [逾期账单清单 + 催收(`OverdueBillsListWidget`)](../scenarios/billing/exception-overdue-bills.md)
- 🚧 [月度账单生成 vs 收款对比(`MonthlyBillingVsCollectionChart`)](../scenarios/billing/audit-monthly-billing-vs-collection.md)
- 🚧 [activitylog 审计追溯](../scenarios/billing/audit-activitylog-trace.md)
## 跨域引用
本子模块引用以下跨域共享概念:
- [业户](../../cross/concepts/resident.md) — 账单收方
- [房屋单元](../../cross/concepts/housing-unit.md) — `asset_id`,账单关联房屋
- [组织结构](../../cross/concepts/org-hierarchy.md) — `community_id`,物业项目归属
## 跨子模块引用
- [adhoc · CollectionOrder 与 Receipt](../concepts/adhoc/collection-order-and-receipt.md) — 收款侧的核心对象
- [meter · 账单生成的三层分层](../concepts/meter/bill-generation-pipeline.md) — 计量账单的生成器
- [prepaid · Consume 走 CollectionType=Bill](../concepts/prepaid/consume-via-bill-collection-type.md) — 预存款抵账单的资金流
- [meter · 表退役与读数锁定](../concepts/meter/decommission-and-locking.md) — 类似的"状态机+守护"对比
- [deposit · 账户与流水](../concepts/deposit/deposit-account-vs-transaction.md) — 双对象模式对比
## 相关代码
- 模型:[`Bill.php`](../../../packages/prop-acc/src/Models/Bill.php)、[`CollectionOrderBill.php`](../../../packages/prop-acc/src/Models/CollectionOrderBill.php)
- 枚举:`BillStatus`(6 种)、`BillType``BillingMergeStrategy``FeeTypeBillType`
- Policy:`BillPolicy`(7 个方法:update / delete / deleteAny / void / collect / suspend / resume)
- 业务 Actions(src/Actions/Bills/):
- [`VoidBillAction`](../../../packages/prop-acc/src/Actions/Bills/VoidBillAction.php)
- [`SplitBillAction`](../../../packages/prop-acc/src/Actions/Bills/SplitBillAction.php)
- [`SuspendBillAction`](../../../packages/prop-acc/src/Actions/Bills/SuspendBillAction.php)
- [`ResumeBillAction`](../../../packages/prop-acc/src/Actions/Bills/ResumeBillAction.php)
- `BulkDeleteBillsAction`(智能批删)
- Filament Resource:[`packages/prop-acc/src/Filament/Resources/Bills/`](../../../packages/prop-acc/src/Filament/Resources/Bills/)
- Filament Actions(UI 入口):8 个(CollectPayment / BatchCollectPayment / GeneratePeriodicBills / Split / Suspend / Resume / Void / BulkDelete)
- Widgets:`BillingStatsOverviewWidget``MonthlyBillingVsCollectionChart``FeeTypeRevenueDistributionChart``OverdueBillsListWidget``MonthlyRevenueTrendChart`
- 业务设计决策:`packages/prop-acc/issue.md` 的 Q6 段(最详细的 issue 之一)
## 相关文档
- [prop-acc 域知识地图](knowledge-map.md)
- [prop-acc 域首页](../index.md)
- [adhoc 子模块知识地图](adhoc-knowledge-map.md)
- [deposit 子模块知识地图](deposit-knowledge-map.md)
- [prepaid 子模块知识地图](prepaid-knowledge-map.md)
- [meter 子模块知识地图](meter-knowledge-map.md)
- [跨域协作地图](../../cross/maps/cross-domain-map.md)
---
> [!info] 概念已完成,场景待补
> 本轮(轮 1)产出:6 个概念 + 本子模块地图 + 域总图更新。
> 下一轮(轮 2)产出:16 个场景文档,基于本知识地图骨架填充。

View File

@@ -23,7 +23,7 @@ last_review: 2026-05-25
| prepaid | 预存款 | 业户预存,自动抵扣月度账单 | [prepaid 知识地图](prepaid-knowledge-map.md) | ✅ 16 场景 + 6 概念 + 1 地图 = 23 篇 | | prepaid | 预存款 | 业户预存,自动抵扣月度账单 | [prepaid 知识地图](prepaid-knowledge-map.md) | ✅ 16 场景 + 6 概念 + 1 地图 = 23 篇 |
| deposit | 保证金 | 装修押金等代管资金,完工后退还 | [deposit 知识地图](deposit-knowledge-map.md) | ✅ 18 场景 + 6 概念 + 1 地图 = 25 篇 | | deposit | 保证金 | 装修押金等代管资金,完工后退还 | [deposit 知识地图](deposit-knowledge-map.md) | ✅ 18 场景 + 6 概念 + 1 地图 = 25 篇 |
| meter | 计量表 | 水表/电表/燃气表,抄表生成账单 | [meter 知识地图](meter-knowledge-map.md) | ✅ 14 场景 + 6 概念 + 1 地图 = 21 篇 | | meter | 计量表 | 水表/电表/燃气表,抄表生成账单 | [meter 知识地图](meter-knowledge-map.md) | ✅ 14 场景 + 6 概念 + 1 地图 = 21 篇 |
| billing | 账单 | 周期性账单 + 计量账单 | _待补_ | 🚧 | | billing | 账单 | 周期性账单 + 计量账单 | [billing 知识地图](billing-knowledge-map.md) | 🟡 6 概念已完成,16 场景待补 |
| payment-order | 收款订单 | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 | | payment-order | 收款订单 | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 |
| receipt | 收据 | 成功收款后生成的凭证 | _待补_ | 🚧 | | receipt | 收据 | 成功收款后生成的凭证 | _待补_ | 🚧 |