253 lines
9.5 KiB
Markdown
253 lines
9.5 KiB
Markdown
|
|
---
|
||
|
|
title: prop-acc · meter · 表退役与读数锁定
|
||
|
|
aliases:
|
||
|
|
- 表退役
|
||
|
|
- decommission
|
||
|
|
- 读数锁定
|
||
|
|
- reading lock
|
||
|
|
- MeterDecommissionReason
|
||
|
|
tags:
|
||
|
|
- 概念
|
||
|
|
- prop-acc
|
||
|
|
- 计量表
|
||
|
|
- 数据完整性
|
||
|
|
audience:
|
||
|
|
- 业务人员
|
||
|
|
- 架构师
|
||
|
|
status: 已发布
|
||
|
|
sub_feature: meter
|
||
|
|
last_review: 2026-05-25
|
||
|
|
code_version: 2026-05-22
|
||
|
|
---
|
||
|
|
|
||
|
|
# 表退役与读数锁定
|
||
|
|
|
||
|
|
Meter / MeterReading 模块有**两套不可变保护机制**保证数据完整性:
|
||
|
|
|
||
|
|
1. **表退役 (Decommission)** — 表停用后只读,不能改 / 不能删 / 不能继续抄
|
||
|
|
2. **读数锁定 (Reading lock)** — Reading 一经创建不可改;**一旦生成 Bill 更不可改 / 不可删**
|
||
|
|
|
||
|
|
两套机制保证**审计可追溯**,防止"事后改数据"导致账单与历史不符。
|
||
|
|
|
||
|
|
## 第 1 套:表退役机制
|
||
|
|
|
||
|
|
### 5 种退役原因
|
||
|
|
|
||
|
|
`MeterDecommissionReason` 枚举:
|
||
|
|
|
||
|
|
| 枚举 | 中文 | 业务场景 |
|
||
|
|
|---|---|---|
|
||
|
|
| `Damaged` | 损坏 | 表内电路烧 / 表头读不出 / 物理损坏 |
|
||
|
|
| `Replaced` | 更换 | 校验未通过 / 老化主动换,新表带 `-R1` 后缀(详见 [[replacement-chain]]) |
|
||
|
|
| `Removed` | 拆除 | 房屋拆迁 / 业主搬走永久弃用 / 重装时拆掉 |
|
||
|
|
| `Expired` | 到期 | 表的法定使用年限到(法律规定,需校验后定换或保留)|
|
||
|
|
| `Calibration` | 校验 | 送检校验中暂停使用(校验后可重启 / 退役)|
|
||
|
|
|
||
|
|
### 退役后的行为
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
stateDiagram-v2
|
||
|
|
[*] --> InUse : 装机
|
||
|
|
InUse --> InUse : 抄表 / 编辑配置
|
||
|
|
InUse --> Decommissioned : decommission()
|
||
|
|
Decommissioned --> [*]
|
||
|
|
Decommissioned --> Decommissioned : 只读,不可改 / 不可删 / 不能抄
|
||
|
|
```
|
||
|
|
|
||
|
|
退役表(`is_active=false` + `decommissioned_at` 已填)的能力对照:
|
||
|
|
|
||
|
|
| 操作 | InUse(`is_active=true`)| **Decommissioned(`is_active=false`)** |
|
||
|
|
|---|---|---|
|
||
|
|
| `EditAction`(改 code / multiplier / fee_type) | ✅ | **❌**(UI 灰化 + Policy 拦截)|
|
||
|
|
| `ReplaceMeterAction`(换表)| ✅ | **❌**(已退役无可换)|
|
||
|
|
| 录新 reading | ✅ | **❌**(业务上无表可读)|
|
||
|
|
| 看历史 reading | ✅ | ✅(只读)|
|
||
|
|
| 看历史 Bill | ✅ | ✅(只读)|
|
||
|
|
| `DeleteAction`(物理删除)| **❌**(UI 移除 + Policy 拦截)| **仅允许"已退役 + 无任何读数"**(罕见)|
|
||
|
|
|
||
|
|
> [!warning] 为什么退役表不能改 code / multiplier
|
||
|
|
> 退役表是**历史档案**。改 code / fee_type / multiplier 不会回填到历史 reading / Bill,会让"历史档案"和"当时实际计费配置"对不上号:
|
||
|
|
>
|
||
|
|
> | 反例 | 后果 |
|
||
|
|
> |---|---|
|
||
|
|
> | 已退役表把 multiplier 从 1 改成 10 | 业户翻历史账单,看到 reading consumption 显示翻 10 倍,但当时账单金额没翻 → 业户困惑、质疑系统数据准确性 |
|
||
|
|
> | 已退役表把 code 改个名 | 历史抄表照片上的物理表号对不上系统 code → 审计追溯断链 |
|
||
|
|
>
|
||
|
|
> `EditAction` 在 `is_active=false` 时**三处**(Table 行 / ViewMeter / EditMeter)隐藏,`MeterPolicy::update()` 服务端兜底。
|
||
|
|
|
||
|
|
### 退役表的物理删除
|
||
|
|
|
||
|
|
`MeterPolicy::delete()` 默认拦截 —— 退役表是**历史档案**,正常情况下不该删。**唯一允许**:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// MeterPolicy::delete()
|
||
|
|
public function delete(AuthUser $user, Meter $meter): bool
|
||
|
|
{
|
||
|
|
return $user->can('delete meters')
|
||
|
|
&& ! $meter->is_active // 必须先退役
|
||
|
|
&& ! $meter->hasReadings(); // 且无任何读数(罕见,只在"误建表后未抄过"清理)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**业务场景**:某物业误建了张表(填错 asset),还没抄表就发现错了 → 退役 + 删除。
|
||
|
|
**反例**:已抄过表的表**绝不能删**,删了会级联抹掉历史 reading + Bill 关联,等于消灭审计证据。
|
||
|
|
|
||
|
|
> [!info] 历史:issue.md Q5 第二轮的修复
|
||
|
|
> 原本 `MeterPolicy` 是**空壳**(从父类继承默认 allow),Table 上的 `DeleteAction` / `DeleteBulkAction` / `EditMeter` 上的 `DeleteAction` 全暴露 → 业务人员一键级联删历史。第二轮修复:
|
||
|
|
>
|
||
|
|
> - 删 Table 行 `DeleteAction`
|
||
|
|
> - 删 Table toolbar `DeleteBulkAction`
|
||
|
|
> - 删 EditMeter 页 `DeleteAction`
|
||
|
|
> - `MeterPolicy::delete()` 服务端兜底
|
||
|
|
>
|
||
|
|
> 三处 UI 入口移除 + 一层 Policy 防御 = 双保险。
|
||
|
|
|
||
|
|
## 第 2 套:Reading 锁定机制
|
||
|
|
|
||
|
|
### Reading 创建后不可改
|
||
|
|
|
||
|
|
`MeterReading` 一经创建**只读**(模型设计,无 Update 入口):
|
||
|
|
|
||
|
|
| 字段 | 创建后可改吗 |
|
||
|
|
|---|---|
|
||
|
|
| `current_reading` | ❌ |
|
||
|
|
| `consumption` | ❌(算出来的)|
|
||
|
|
| `read_at` | ❌ |
|
||
|
|
| `source` | ❌ |
|
||
|
|
| `operated_by` | ❌ |
|
||
|
|
| `photo_url` | ❌ |
|
||
|
|
| `memo` | ❌(严格)|
|
||
|
|
| `bill_id` | ❌(由系统填写,业务人员不动)|
|
||
|
|
|
||
|
|
**业务上"修正错误读数"** 走专门流程([[exception-readings-locked-after-bill]]):
|
||
|
|
|
||
|
|
1. **如果还没生成 Bill**:理论上可以删 reading + 重建(看 Policy 允许)
|
||
|
|
2. **如果已生成 Bill**:必须先**作废 Bill** → 改 reading(实际是建新 reading + 标旧 reading 作废)→ 重生成 Bill
|
||
|
|
|
||
|
|
### 已生成 Bill 的 Reading 更严
|
||
|
|
|
||
|
|
如果 `MeterReading.bill_id != null`,有**双锁**:
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart TD
|
||
|
|
A[Reading 创建] --> B[Reading 只读<br/>第 1 锁]
|
||
|
|
B --> C{有 bill_id 吗?}
|
||
|
|
C -->|有| D[更不可改<br/>更不可删<br/>第 2 锁]
|
||
|
|
C -->|无| E[暂时可删<br/>看 Policy]
|
||
|
|
```
|
||
|
|
|
||
|
|
`MeterReadingPolicy`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// MeterReadingPolicy.php(伪代码)
|
||
|
|
public function update(AuthUser $user, MeterReading $reading): bool
|
||
|
|
{
|
||
|
|
// 几乎永远 false(Reading 不可改)
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function delete(AuthUser $user, MeterReading $reading): bool
|
||
|
|
{
|
||
|
|
return $user->can('delete meter readings')
|
||
|
|
&& $reading->bill_id === null; // 已生成 Bill 不可删
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
UI 同步:
|
||
|
|
|
||
|
|
- `MeterReadingsRelationManager` 上 `EditAction->visible(fn ($r) => $r->bill_id === null)`
|
||
|
|
- 已生成 Bill 的 reading 在 UI 上 Edit / Delete 按钮**自动灰化**
|
||
|
|
|
||
|
|
> [!info] 历史:issue.md Q5 第二轮的修复
|
||
|
|
> 原本 `MeterReadingsRelationManager` 行级 `DeleteAction` 完全暴露 → 已落账的 reading 可被删除 → Bill 数据脱节、审计断链。第二轮修复:
|
||
|
|
>
|
||
|
|
> - 删 `MeterReading.DeleteAction`
|
||
|
|
> - `EditAction` 加 `->visible(bill === null)`
|
||
|
|
> - `MeterReadingPolicy::update()` / `delete()` 服务端兜底要求 `bill === null`
|
||
|
|
|
||
|
|
## 两套机制的协同
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart LR
|
||
|
|
A[业务人员发现某 reading 数据错] --> B{Reading 有 bill 吗?}
|
||
|
|
|
||
|
|
B -->|没有| C[尝试删 reading<br/>看 Policy 是否允许]
|
||
|
|
C --> D[重建 reading]
|
||
|
|
D --> E[重生成 Bill]
|
||
|
|
|
||
|
|
B -->|有| F[先作废 Bill<br/>本身是复杂流程]
|
||
|
|
F --> G[Reading 上 bill_id 解除<br/>视设计]
|
||
|
|
G --> H[新建一条修正 reading<br/>+ 标旧 reading 作废]
|
||
|
|
H --> I[重生成 Bill]
|
||
|
|
```
|
||
|
|
|
||
|
|
第二种情况(已生成 Bill)是 issue.md Q5 "待补 / 已知问题"段中提到的:
|
||
|
|
|
||
|
|
> **"作废已生成 Bill 的 MeterReading"组合流程**:当前 Reading 一旦生成 Bill 即锁定。要修正错误读数需要先**作废 Bill** 再改 Reading 再重新生成。这个组合流程类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计),需要单独设计。
|
||
|
|
|
||
|
|
**当前实施**:不支持自动化,运维 / 高权限人员手工通过 tinker 处理。常见场景见 [[exception-readings-locked-after-bill]]。
|
||
|
|
|
||
|
|
## 业务人员视角
|
||
|
|
|
||
|
|
### 退役表
|
||
|
|
|
||
|
|
通常场景:
|
||
|
|
|
||
|
|
- 检定到期 → `Calibration` → 送校验 → 校验后视情况
|
||
|
|
- 校验未过 → `Replaced` → 走 [[replacement-chain|换表]]
|
||
|
|
- 物理损坏 → `Damaged` → 走 [[replacement-chain|换表]] 或 `Removed` 不换
|
||
|
|
- 业户搬走永久弃用 → `Removed`
|
||
|
|
- 法定使用年限到 → `Expired` → 视情况换 / 退役
|
||
|
|
|
||
|
|
### 已锁定的 Reading
|
||
|
|
|
||
|
|
业务上常见的"修正错误读数"诉求:
|
||
|
|
|
||
|
|
- 抄表员手抖录错(2800 录成 2080)→ 已生成 Bill → 走作废 Bill 流程
|
||
|
|
- 集抄数据错 → 同上
|
||
|
|
- 业户拍照说"实际不是这个数字"→ 拿物理表当前读数对照 → 决定修正与否
|
||
|
|
|
||
|
|
修正不是常规操作,**预防胜于补救** —— 录入时多审核 + 拍照存证([[reading-source-and-photo-proof]])。
|
||
|
|
|
||
|
|
## 架构师视角
|
||
|
|
|
||
|
|
退役 + 锁定两套机制是**严肃的数据治理**:
|
||
|
|
|
||
|
|
- 保证账单与抄表历史**一致性**(不能修改产生过账单的数据)
|
||
|
|
- 保证物业有**审计抗辩能力**(被业户质疑时拿出原始记录)
|
||
|
|
- 保证**长期数据可信**(5 年、10 年后的查询仍准确)
|
||
|
|
|
||
|
|
替代方案(允许改)的代价:
|
||
|
|
|
||
|
|
- 业户质疑账单 → 物业拿不出真实凭证
|
||
|
|
- 内审查不出账面历史 vs 当时实际配置不一致
|
||
|
|
- 法律纠纷物业举证不利
|
||
|
|
|
||
|
|
## 常见问题
|
||
|
|
|
||
|
|
> [!question] 已退役表的 Reading 还能新建吗?
|
||
|
|
> 不能。物理表不存在了,业务上无可读。系统层面 `MeterReadingsRelationManager` 在 `is_active=false` 时隐藏 Create 按钮(应该实现,若没有需补)。
|
||
|
|
|
||
|
|
> [!question] 错误退役(其实表还好)能撤销吗?
|
||
|
|
> Policy 设计上**不允许**(`is_active` 从 false 改 true 没 UI 入口)。如果真需要撤销:
|
||
|
|
>
|
||
|
|
> - tinker 直接改字段(运维操作,留备注)
|
||
|
|
> - 或退役表保留 + 建一张新表(`is_active=true`,但失去更换链关联)
|
||
|
|
>
|
||
|
|
> **预防胜于补救**:退役前确认。
|
||
|
|
|
||
|
|
> [!question] Reading 和 Bill 哪个先?
|
||
|
|
> 时间上:Reading 先(抄表),Bill 后(生成账单)。数据上:Reading 创建即写入,Bill 是 reading 的派生。一旦 Bill 创建,会回写 `Reading.bill_id`。
|
||
|
|
|
||
|
|
> [!question] 删 Bill 会自动解锁 Reading 吗?
|
||
|
|
> 当前**不会自动**(Reading.bill_id 不会因为 Bill 删除而自动 nullify,看具体 cascade 配置)。Bill 作废后,需要业务流程明确"是否要重新生成"——如果重新生成,新 Bill 创建时回写 `Reading.bill_id`(覆盖旧的)。
|
||
|
|
|
||
|
|
## 相关文档
|
||
|
|
|
||
|
|
- [[meter-vs-meter-reading]]
|
||
|
|
- [[replacement-chain]]
|
||
|
|
- [[bill-generation-pipeline]]
|
||
|
|
- [[exception-readings-locked-after-bill]]
|
||
|
|
- [[decommission-without-replacement]]
|
||
|
|
- [[replace-broken-meter]]
|