--- title: prop-acc · meter · 场景 - 已生成 Bill 的 Reading 锁定,要修正需作废 Bill aliases: - Reading 锁定 - 已落账 reading 修正 - exception-readings-locked-after-bill - 作废 Bill 重算 - 场景-已落账读数修正 tags: - 场景 - prop-acc - 计量表 - 异常 - 数据完整性 audience: - 业务人员 - 架构师 status: 已发布 sub_feature: meter last_review: 2026-05-26 code_version: 2026-05-22 --- # 场景:已生成 Bill 的 Reading 锁定,要修正需作废 Bill `MeterReading` 一经创建**不可改**;**一旦生成 Bill 更不可改 / 不可删**(双锁,见 [[decommission-and-locking]])。如果发现已落账 reading 数据错(抄表录错 / 集抄错传 / 业户质疑成功),需走**复杂修正流程**:**先作废 Bill → 改 reading(实际是建新 reading + 标旧 reading 作废)→ 重生成 Bill**。 ## 典型情境 > [!example] 真实情境 > 5 月底抄表 + 自动生成账单。陈先生 5 月电费账单 ¥1,200(2,800 度,异常高,触发 [[exception-high-consumption|高用量预警]])。 > > 物业派人排查,发现: > - **抄表员李师傅手抖**:把 1,500 录成 2,500 > - 实际本月用量 ~1,500 度,账单应 ¥600 左右(不是 1,200) > > 陈先生质疑成功,物业要把账单从 1,200 改成 ~600。但: > - **Reading 不可改**(系统层面双锁) > - **Bill 已经生成 + 关联 reading** > > 需要走**作废 + 重算**的组合流程。 ## 当前实施状态 > [!warning] **当前系统不支持自动化此流程** > > issue.md Q5 "待补"段明确记录: > > > **"作废已生成 Bill 的 MeterReading"组合流程**:当前 Reading 一旦生成 Bill 即锁定。要修正错误读数需要先**作废 Bill** 再改 Reading 再重新生成。这个组合流程类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计),需要单独设计。等业务方明确"已收款的 Bill 应该怎么撤销"再做(可能涉及红字 Bill / 退款)。 > > **当前替代**:运维 / 高权限人员通过 tinker 手工处理。本场景描述**业务流程层**和**未来的目标态**。 ## 业务人员视角(当前手工处理) ### 第 1 步:确认要修正 - 业户质疑账单 + 提供合理证据(自家拍照 / 历史数据对照) - 物业核对:抄表照片 / 集抄数据 / 物理表 - 内部决定:确实要修正 ### 第 2 步:看账单是否已付 | 账单状态 | 处理路径 | |---|---| | **未付**(Unpaid)| 简单:作废 Bill → 重算 | | **已付**(Paid,业户付现金/微信)| 复杂:作废 Bill → 业户**退款** → 重新出账单 → 业户**重付** | | **已付**(用 [[../prepaid/consume-monthly-property-bill|预存款抵扣]])| 复杂:作废 Bill → 预存款**反向充值** → 重新出账单 → 重新抵扣 | ### 第 3 步:走"作废 Bill"流程 当前**没专用 UI** —— 联系运维 / 高权限人员: ```php // tinker 操作示意(运维) DB::transaction(function () use ($readingId) { $reading = MeterReading::find($readingId); $oldBill = Bill::find($reading->bill_id); // 1. 作废 Bill $oldBill->update([ 'status' => BillStatus::Voided, 'voided_at' => now(), 'voided_reason' => '抄表录错,需重算', ]); // 2. 解锁 reading(把 bill_id 设 null,让它可被处理) $reading->update(['bill_id' => null]); // 或者:标 reading 作废,新建一条修正 reading $reading->update(['voided_at' => now(), 'voided_reason' => '录错']); }); ``` ### 第 4 步:建修正 reading 如果走"建新 reading"路径: ```php $correctReading = MeterReading::create([ 'meter_id' => $oldReading->meter_id, 'read_at' => $oldReading->read_at, // 同抄表日期 'current_reading' => 1500, // 改成正确值 'source' => MeterReadingSource::Manual, 'operated_by' => $currentAdmin->id, 'memo' => "修正:原 reading #{$oldReading->id} 数值录错(2500 → 1500)", ]); ``` ### 第 5 步:重生成 Bill 走 [[bill-generation-pipeline]]:对新 reading 调 `GenerateBillsFromMeterReadingsAction`。 ### 第 6 步:处理已付款的差额 如果旧账单已被业户付了: - **业户付现金**:物业现场退差额(¥1,200 - ¥600 = ¥600) - **微信付**:物业微信退款 - **预存款抵扣**:走"反向 consume"(技术上是 `PrepaidAccount::deposit` 把钱充回 → 然后从新账单扣) ### 第 7 步:通知业户 完整说明: - 旧账单 ¥1,200 已作废(原因:抄表录错) - 新账单 ¥600 - 差额已退还(或预存款已回填) ## 未来目标态(待开发) 类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计): ```mermaid sequenceDiagram participant 业务 participant Filament participant VoidBillAction[待开发] participant 数据库 业务->>Filament: 找到要作废的 Bill → VoidBillAction Filament->>VoidBillAction: handle(bill, reason) VoidBillAction->>数据库: Bill.status=Voided + voided_reason + voided_at VoidBillAction->>数据库: Reading.bill_id=null(解锁) VoidBillAction->>数据库: 若已付:建红字 Receipt + 退款 / 预存款回填 Filament-->>业务: 完成 业务->>Filament: 建修正 MeterReading(同 read_at) Filament->>数据库: 新建 reading Filament->>VoidBillAction[GenerateBills]: handle(new reading) 数据库->>数据库: 建新 Bill(amount=正确值) ``` ## 系统视角:双锁的设计意义 为什么 Reading 创建后不可改(第 1 锁)+ 有 Bill 更不可改(第 2 锁)? | 反例(若允许改)| 后果 | |---|---| | 业务人员直接改 Reading 数据 | 历史 Bill 仍是旧金额,Reading 是新数据 → 不一致 | | 改 Reading 同时改 Bill | 已付的钱怎么办?业户付了 1200 你改成 600 → 600 凭空消失 | | 业户已付 + 物业改 Reading 改小金额 | 物业账面收入虚高(实际收 1200,账面记 600,差额 600 不知去向)| **双锁强制**业务人员走"作废 + 重生成"流程,**留下完整审计痕迹**(旧 Bill voided + 新 Bill 创建 + 退款 / 预存款回填记录)。 ## 业户视角 业户**不直接接触系统层**,只感受: - 提出异议 - 物业核实 - 收到说明:"经核实,5 月账单 ¥1,200 是抄表录错,实际应付 ¥600。已作废原账单,新账单已发,差额 ¥600 已退到您微信" - 收到**红字凭证**(若已付,作废 + 退款) - 收到**新账单** 整个流程业户感知:"物业认错 + 退钱 + 重发账单",是**正面体验**(物业承认错误并改正)。 ## 流水台账(完整修正过程) | 时间 | 动作 | Reading | Bill | |---|---|---|---| | 5/26 | 抄表录错 | #1(current=2500, bill_id=Bill#A) | Bill#A(amount=1200, Unpaid) | | 5/27 | 业户质疑 + 核实 | (不变) | (不变) | | 5/30 | 作废 Bill | #1.voided=true, bill_id=null | Bill#A.status=Voided, voided_reason="抄表录错" | | 5/30 | 建修正 reading | #2(current=1500, bill_id=null) | (无) | | 5/30 | 重生成 Bill | #2.bill_id=Bill#B | Bill#B(amount=600, Unpaid) | | 5/31 | 业户付款 | (不变) | Bill#B.status=Paid | 整条修正记录可审计。 ## 常见问题 > [!question] 如果原账单未付,作废就行,不用退款? > 是的。未付 → 作废 → 不开账单 → 业户没付任何钱。重生成新账单业户直接付新账单即可。 > [!question] 业户已经付了,作废 Bill 之后如何退款? > 看付款方式: > - **现金 / 微信 / POS**:物业按渠道退 > - **预存款抵扣**:`PrepaidAccount::deposit` 反向充值(技术上是建一笔 deposit 流水把钱"还"回预存款余额)→ 然后业户继续用预存款付新账单 > > 当前**没自动化**,需运维 / 业务流程操作。 > [!question] 已经走过红字流程的 deposit/prepaid 模块,meter 为什么没有? > meter 模块**直接产 Bill 不直接产 Receipt**,与 deposit/prepaid 直接产 Receipt 的模式不同。"红字 Bill" 的设计需要 Bill 模型支持(增加 `voided_at` / `voided_reason` 字段 + 业务流程),issue.md Q5 标记为"待补"。 > [!question] 抄表员经常录错怎么办? > 系统层面: > - 强制拍照([[reading-source-and-photo-proof]] + [[read-with-photo-proof]]) > - Form 上显示 previous 读数 + 异常告警(本月差与上月差比超过 X 倍 → 提示) > > 业务层面: > - 抄表员培训 > - 关键抄表数据二次审核(双签) > - 升级集抄([[read-via-iot-remote-source]])减少人工录入 > [!question] "修正 reading" 是建新 reading 还是改旧? > 看实现: > - **改旧**(直接 update)→ 简单但丢失历史(原数据没了) > - **建新 + 标旧 voided**(推荐)→ 复杂但完整保留审计 > > 当前 issue.md 倾向"建新 + 标旧 voided"模式(类似 AdHocEvent VoidAction)。需要给 Reading 表加 `voided_at` / `voided_reason` 字段。 > [!question] 长期不修复这个 gap 有什么风险? > - 业务人员**每次修正都要联系运维**(慢、不可扩展) > - 修正没有标准化流程 → 容易出错(漏退款 / 漏作废) > - 审计困难(运维 tinker 操作的痕迹靠日志,不像 UI 操作那么清晰) > - 业户体验差(响应慢) > > 业务方提需求时优先级会上来。 ## 相关 issue.md 待补 ``` - "作废已生成 Bill 的 MeterReading"组合流程: 类似 AdHocEvent 的 VoidAction(级联废 + 留 voided 审计),需要单独设计。 等业务方明确"已收款的 Bill 应该怎么撤销"再做(可能涉及红字 Bill / 退款) ``` ## 异常分支 - 抄表员录错预防 → [[read-with-photo-proof|拍照存证]] + Form 守护 - 高用量触发预警 → [[exception-high-consumption]] - 表故障导致错读 → [[replace-broken-meter|换表]] + 走本场景修正 ## 相关文档 - [[decommission-and-locking]] - [[bill-generation-pipeline]] - [[exception-high-consumption]] - [[replace-broken-meter]] - [[../adhoc/cancel-amount-error-redo]](adhoc 模块的类似 void 流程参考)