Files
uniprop-manual/prop-acc/concepts/meter/decommission-and-locking.md
Willie 898d3a93a7 meter 子模块 · 轮 1:6 概念 + 知识地图 + 导航更新
写 6 个核心概念到 prop-acc/concepts/meter/:
- meter-vs-meter-reading:物理表配置 + 不可变读数流水双对象;与"账户+流水"模式
  对比(主对象有 balance vs 物理硬件配置;直接产 Receipt vs 通过 Bill 中转)
- replacement-chain:replaced_meter_id + 自动 -R1 后缀 + 初始读数继承;
  nextReplacementCode() 算法 + 整链追溯 + ReplaceMeterAction 流程
- multiplier-and-tiered-pricing:倍率(decimal(10,4),工业表 10x/100x)+
  阶梯计价(progressive 累进算法,非 full-tier 简陋实现)+ min/max 封顶
- bill-generation-pipeline:三层分层 Calculator(纯算)→ Service(查费率+找业主+建账)
  → Action(入口);多调用方共用业务层;prop-acc 后续模块的样板
- reading-source-and-photo-proof:MeterReadingSource 2 种(manual/remote)+
  photo_url 拍照存证;业户对账单争议时的凭证依据
- decommission-and-locking:MeterDecommissionReason 5 种 + Reading 双锁机制
  (创建即不可改;有 bill_id 更不可改/删);issue.md Q5 第二轮修复历史

新建子模块知识地图:
- prop-acc/maps/meter-knowledge-map.md:6 概念入口 + 14 场景预占清单 +
  跨子模块对比(meter 与 deposit/prepaid 的核心差异)+ 代码索引

更新导航:
- prop-acc/maps/knowledge-map.md:域总图 meter 行链 meter 知识地图,状态 🟡
- prop-acc/index.md:同步

下一轮:14 个场景文档(meter/scenarios/),按本知识地图骨架填充。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:56:41 +08:00

9.5 KiB

title, aliases, tags, audience, status, sub_feature, last_review, code_version
title aliases tags audience status sub_feature last_review code_version
prop-acc · meter · 表退役与读数锁定
表退役
decommission
读数锁定
reading lock
MeterDecommissionReason
概念
prop-acc
计量表
数据完整性
业务人员
架构师
已发布 meter 2026-05-25 2026-05-22

表退役与读数锁定

Meter / MeterReading 模块有两套不可变保护机制保证数据完整性:

  1. 表退役 (Decommission) — 表停用后只读,不能改 / 不能删 / 不能继续抄
  2. 读数锁定 (Reading lock) — Reading 一经创建不可改;一旦生成 Bill 更不可改 / 不可删

两套机制保证审计可追溯,防止"事后改数据"导致账单与历史不符。

第 1 套:表退役机制

5 种退役原因

MeterDecommissionReason 枚举:

枚举 中文 业务场景
Damaged 损坏 表内电路烧 / 表头读不出 / 物理损坏
Replaced 更换 校验未通过 / 老化主动换,新表带 -R1 后缀(详见 replacement-chain)
Removed 拆除 房屋拆迁 / 业主搬走永久弃用 / 重装时拆掉
Expired 到期 表的法定使用年限到(法律规定,需校验后定换或保留)
Calibration 校验 送检校验中暂停使用(校验后可重启 / 退役)

退役后的行为

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 → 审计追溯断链

EditActionis_active=false三处(Table 行 / ViewMeter / EditMeter)隐藏,MeterPolicy::update() 服务端兜底。

退役表的物理删除

MeterPolicy::delete() 默认拦截 —— 退役表是历史档案,正常情况下不该删。唯一允许:

// 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,有双锁:

flowchart TD
  A[Reading 创建] --> B[Reading 只读<br/>第 1 锁]
  B --> C{有 bill_id 吗?}
  C -->|有| D[更不可改<br/>更不可删<br/>第 2 锁]
  C -->|无| E[暂时可删<br/>看 Policy]

MeterReadingPolicy:

// 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 同步:

  • MeterReadingsRelationManagerEditAction->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

两套机制的协同

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-chainRemoved 不换
  • 业户搬走永久弃用 → Removed
  • 法定使用年限到 → Expired → 视情况换 / 退役

已锁定的 Reading

业务上常见的"修正错误读数"诉求:

  • 抄表员手抖录错(2800 录成 2080)→ 已生成 Bill → 走作废 Bill 流程
  • 集抄数据错 → 同上
  • 业户拍照说"实际不是这个数字"→ 拿物理表当前读数对照 → 决定修正与否

修正不是常规操作,预防胜于补救 —— 录入时多审核 + 拍照存证(reading-source-and-photo-proof)。

架构师视角

退役 + 锁定两套机制是严肃的数据治理:

  • 保证账单与抄表历史一致性(不能修改产生过账单的数据)
  • 保证物业有审计抗辩能力(被业户质疑时拿出原始记录)
  • 保证长期数据可信(5 年、10 年后的查询仍准确)

替代方案(允许改)的代价:

  • 业户质疑账单 → 物业拿不出真实凭证
  • 内审查不出账面历史 vs 当时实际配置不一致
  • 法律纠纷物业举证不利

常见问题

[!question] 已退役表的 Reading 还能新建吗? 不能。物理表不存在了,业务上无可读。系统层面 MeterReadingsRelationManageris_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(覆盖旧的)。

相关文档