diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index f0993f0..f6fb994 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -197,6 +197,10 @@ }, "active": "849c5ff8936a2b67", "lastOpenFiles": [ + "prop-acc/concepts/meter/reading-source-and-photo-proof.md", + "prop-acc/concepts/meter/bill-generation-pipeline.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/scenarios/prepaid/audit-low-balance-and-overdue.md", @@ -221,10 +225,6 @@ "prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "prop-acc/scenarios/deposit/deposit-additional-topup.md", "prop-acc/maps/prepaid-knowledge-map.md", - "prop-acc/concepts/prepaid/auto-deduction-design.md", - "prop-acc/maps/deposit-knowledge-map.md", - "prop-acc/concepts/prepaid/consume-via-bill-collection-type.md", - "prop-acc/concepts/prepaid/transaction-types.md", "prop-acc/concepts/prepaid", "prop-acc/scenarios/deposit", "prop-acc/concepts/deposit", diff --git a/prop-acc/concepts/meter/bill-generation-pipeline.md b/prop-acc/concepts/meter/bill-generation-pipeline.md new file mode 100644 index 0000000..3c2144c --- /dev/null +++ b/prop-acc/concepts/meter/bill-generation-pipeline.md @@ -0,0 +1,294 @@ +--- +title: prop-acc · meter · 账单生成的三层分层 +aliases: + - 账单生成 pipeline + - Calculator Service Action 三层 + - MeterBillCalculator + - MeterBillGenerationService + - GenerateBillsFromMeterReadingsAction +tags: + - 概念 + - prop-acc + - 计量表 + - 架构 + - 计费 +audience: + - 架构师 + - 业务人员 +status: 已发布 +sub_feature: meter +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 账单生成的三层分层 + +从"一条 MeterReading"到"一张 Bill",中间经过三层代码,每层职责清晰、可独立测试: + +1. **`MeterBillCalculator`** — 纯算函数(无 DB IO),拿用量 + RatePlan,算出金额 +2. **`MeterBillGenerationService`** — 查费率 + 找业主 + 建 Bill 数据库记录 +3. **`GenerateBillsFromMeterReadingsAction`** — 业务入口,接收一组 reading,触发整个流程 + +> [!info] 为什么这种分层是 prop-acc 的"样板" +> issue.md Q5 提到 meter 是 prop-acc 里**最成熟**的模块,后续 deposit / prepaid / adhoc 学习了它的"业务分层方法"。这条 Calculator → Service → Action 的链路设计,**值得作为新模块的参考蓝本**。 + +## 三层职责清单 + +| 层 | 类名 | 职责 | 依赖 | +|---|---|---|---| +| **Calculator** | `MeterBillCalculator` | 纯算:用量 → 金额(阶梯 + 倍率 + min/max)| 无(纯函数)| +| **Service** | `MeterBillGenerationService` | 业务编排:从 Reading 查 fee_type → 查 RatePlan → 找 asset 业主 → 调 Calculator → 建 Bill | DB | +| **Action** | `GenerateBillsFromMeterReadingsAction` | 业务入口:接收一组 Reading → 校验 → 逐条调 Service | DB + 事件 | + +## 调用栈 + +```mermaid +sequenceDiagram + participant 业务[业务人员/定时任务] + participant Filament + participant Action[GenerateBillsFromMeterReadingsAction] + participant Service[MeterBillGenerationService] + participant Calc[MeterBillCalculator] + participant DB + + 业务->>Filament: 触发"生成本月账单"按钮 + Filament->>Action: handle([reading1, reading2, ..., readingN]) + + loop 每个 reading + Action->>Action: 校验:reading.bill_id == null + Action->>Service: generateBillForReading(reading) + Service->>DB: 查 Meter.fee_type → 查 RatePlan + RateTier + Service->>DB: 查 asset_id 关联的业户(community_asset_users) + Service->>Calc: calculate(consumption, ratePlan, min, max) + Calc-->>Service: amount = 148 + Service->>DB: 建 Bill(amount, asset, resident, sourceable=reading) + Service->>DB: 更新 reading.bill_id = bill.id + Service-->>Action: ok + end + + Action-->>Filament: 完成 + 报告(已生成 N 张 Bill) +``` + +## 每层细节 + +### Layer 1:`MeterBillCalculator`(纯算) + +```php +// 伪代码示意 +class MeterBillCalculator +{ + public function calculate( + float $consumption, + RatePlan $ratePlan, + ?float $minAmount, + ?float $maxAmount, + ): float { + $amount = $this->calculateTiered($consumption, $ratePlan->tiers); + if ($maxAmount !== null && $amount > $maxAmount) { + $amount = $maxAmount; + } + if ($minAmount !== null && $amount < $minAmount) { + $amount = $minAmount; + } + return $amount; + } + + public function calculateTiered(float $consumption, Collection $tiers): float + { + // progressive 累进算法,见 multiplier-and-tiered-pricing + } +} +``` + +**特点**: + +- 无 DB 查询 +- 无业务对象关联(只接受参数) +- 完全可测试(给定输入 → 期望输出) +- 单元测试覆盖率 100% 容易达到 + +### Layer 2:`MeterBillGenerationService`(业务编排) + +```php +// 伪代码示意 +class MeterBillGenerationService +{ + public function __construct( + private MeterBillCalculator $calculator, + ) {} + + public function generateBillForReading(MeterReading $reading): Bill + { + $meter = $reading->meter; + $feeType = $meter->feeType; + $ratePlan = $feeType->currentRatePlan; + $asset = $meter->asset; + $resident = $this->findCurrentResident($asset); // 关键业务逻辑 + + $amount = $this->calculator->calculate( + $reading->consumption, + $ratePlan, + $ratePlan->min_amount, + $ratePlan->max_amount, + ); + + return DB::transaction(function () use ($reading, $amount, /* ... */) { + $bill = Bill::create([ + 'community_id' => $meter->community_id, + 'asset_id' => $meter->asset_id, + 'resident_id' => $resident?->id, + 'fee_type_id' => $meter->fee_type_id, + 'amount' => $amount, + 'sourceable_type' => MeterReading::class, + 'sourceable_id' => $reading->id, + 'status' => BillStatus::Unpaid, + 'due_at' => /* 计算到期日 */, + ]); + + $reading->update(['bill_id' => $bill->id]); + + return $bill; + }); + } +} +``` + +**特点**: + +- 查 DB(RatePlan / asset / resident) +- 业务规则(找当前业主、计算到期日) +- 事务边界(确保 Bill 建 + Reading 回写同时成功) +- 不直接处理 UI / 用户输入 + +### Layer 3:`GenerateBillsFromMeterReadingsAction`(入口) + +```php +// 伪代码示意 +class GenerateBillsFromMeterReadingsAction +{ + public function __construct( + private MeterBillGenerationService $service, + ) {} + + public function handle(Collection $readings): array + { + $generated = []; + $skipped = []; + + foreach ($readings as $reading) { + if ($reading->bill_id !== null) { + $skipped[] = ['reading' => $reading, 'reason' => 'already_billed']; + continue; + } + + try { + $bill = $this->service->generateBillForReading($reading); + $generated[] = $bill; + } catch (\Exception $e) { + $skipped[] = ['reading' => $reading, 'reason' => $e->getMessage()]; + } + } + + return [ + 'generated' => $generated, + 'skipped' => $skipped, + ]; + } +} +``` + +**特点**: + +- 业务入口(给 Filament / Console / 定时任务调用) +- 处理批量 +- 容错(单条失败不影响其他) +- 返回结构化结果供调用方报告 / 推送 + +## 调用方 + +`GenerateBillsFromMeterReadingsAction` 的调用方: + +| 调用方 | 场景 | +|---|---| +| **Filament Action**(`ViewMeter` 上的"生成账单"按钮)| 业务人员手动触发(单表 / 多表)| +| **`MeterReadingsImporter`**(批量导入完成后)| 导入抄表数据后自动触发账单生成 | +| **`MeterReadingsRelationManager`** 单录后(可选)| 抄表后即生成账单(配置项)| +| **定时任务**(月初自动)| 待补(类似 prepaid 的 [[../prepaid/auto-deduction-design]]) | + +**关键**:所有调用方**共用同一份 Action**,业务逻辑只写一遍。 + +## 为什么分层 + +| 不分层(全部塞 Filament Action)| **三层分层(本设计)** | +|---|---| +| 一个 Filament Action 500+ 行 | Calculator 100 行 + Service 200 行 + Action 100 行 | +| 测试要 mock UI 才能跑 | Calculator 直接单元测试 | +| 复用困难(其他调用方要复制粘贴)| 多调用方共用 Action / Service | +| 业务逻辑藏在 UI 层(违反层次原则)| 业务逻辑在 Action 层,UI 只是入口 | +| 重构成本极高 | 各层独立演化 | + +issue.md 多处提到"清理内联业务逻辑"的迁移(deposit / prepaid / adhoc 都做过),meter 模块**一开始就是这样设计的**,所以没经历这种痛。 + +## 测试金字塔 + +```mermaid +flowchart TD + A[Action 层
少数 Feature 测试] --> B[Service 层
中等数 Feature 测试] + B --> C[Calculator 层
大量 Unit 测试] +``` + +- Calculator:大量边界情况单元测试(0 用量、负用量、阶梯边界、min max 触发等) +- Service:Feature 测试覆盖业务规则(找业户失败、RatePlan 不存在等) +- Action:Feature 测试覆盖批量逻辑(部分失败、空集合、重复 reading 等) + +## 性能与并发 + +- 单 reading 生成 Bill: ~50ms(查 RatePlan + 业户 + 建 Bill + 更新 reading) +- 批量 100 reading: ~5 秒(顺序执行) +- 大规模批量(>1000):分 chunk 处理(Action 内置 / 调用方分批) +- 并发安全:每次 `generateBillForReading` 在事务内,reading.bill_id 是数据库约束(unique 也好,not null check 也好),不会重复建 Bill + +## 异常处理 + +| 异常 | Service 行为 | Action 报告 | +|---|---|---| +| RatePlan 不存在 | 抛 `RatePlanNotFoundException` | skipped[reason=rate_plan_not_found] | +| asset 没绑业户 | 视设计 — 抛 / 建匿名 Bill / 不建 | skipped[reason=no_resident] | +| Bill 建表 DB 错误 | 抛(回滚事务)| skipped[reason=db_error] | +| reading 已有 bill_id | Service 不接 / Action 跳过 | skipped[reason=already_billed] | +| consumption 是负数(读数倒走)| 视设计 — 抛 / 容错 | skipped[reason=negative_consumption] | + +## 业务人员视角 + +在 Filament 后台**几乎感受不到**这三层: + +- 看到的就是 `ViewMeter` 或 `ListMeters` 上的"生成账单"按钮 +- 点击 → 弹出选择 reading 的 Modal → 提交 +- 系统报告"X 张账单已生成" +- 失败的 reading 列出原因 + +底层的 Calculator / Service / Action 对业务人员透明。 + +## 架构师视角 + +这套分层是**评审新功能**的参考: + +| 新需求 | 该改哪一层 | +|---|---| +| 加新计价算法(累退 / 二次方等)| Calculator 层(纯算)| +| 加业户关联规则(房屋多业主时按比例分摊)| Service 层(业务编排)| +| 加新触发场景(微信小程序业户主动结账)| Action 层(入口)+ 新 controller | +| 改账单字段(加新字段、改 due_at 计算)| Service 层 | +| 加批量优化(并行 / 队列)| Action 层 | + +每层都是**单一职责**,改动局部、不会污染其他层。 + +## 相关文档 + +- [[meter-vs-meter-reading]] +- [[multiplier-and-tiered-pricing]] +- [[decommission-and-locking]] +- [[generate-bill-tiered-pricing]] +- [[generate-bill-with-multiplier]] +- [[generate-bill-min-max-cap]] +- [[../prepaid/auto-deduction-design]](类似的"Service + Action"分层借鉴) diff --git a/prop-acc/concepts/meter/multiplier-and-tiered-pricing.md b/prop-acc/concepts/meter/multiplier-and-tiered-pricing.md new file mode 100644 index 0000000..9210708 --- /dev/null +++ b/prop-acc/concepts/meter/multiplier-and-tiered-pricing.md @@ -0,0 +1,244 @@ +--- +title: prop-acc · meter · 倍率与阶梯计价 +aliases: + - 倍率 + - 阶梯计价 + - multiplier + - tiered pricing + - min max 封顶 +tags: + - 概念 + - prop-acc + - 计量表 + - 计费 +audience: + - 业务人员 + - 财务 + - 架构师 +status: 已发布 +sub_feature: meter +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 倍率与阶梯计价 + +抄表生成账单的金额由三层叠加算出:**倍率(multiplier)** × **阶梯计价(tiered)** + **min/max 封顶**。这三层都在 `MeterBillCalculator` 内实现(纯算,无 DB IO),是 prop-acc 模块中**最严谨的算法之一**。 + +## 一句话总览 + +``` +本月用量 = (current_reading - previous_reading) × multiplier +本月金额 = 阶梯计价(本月用量, RatePlan) +最终金额 = clamp(本月金额, min, max) +``` + +详见下文每层展开。 + +## 第 1 层:倍率(multiplier) + +### 用途 + +工业 / 集团表的**实际计量值远大于表头数字**: + +| 表型 | 表头读数 | multiplier | 实际用量 | +|---|---|---|---| +| 家用单相表 | 280 度 | 1 | 280 度 | +| 三相工业表 | 28 度 | **10** | 280 度 | +| 大型集团表(高压侧)| 28 度 | **100** | 2,800 度 | + +物理原理:工业表为了在小表头显示大用量,内部有变压 / 分流比。multiplier 就是这个比值。 + +### 字段精度 + +```php +// migration +$table->decimal('multiplier', 10, 4); +// model casts +'multiplier' => 'decimal:4', +``` + +decimal(10,4) 精度足够工业场景(常见 1.0 / 10.0 / 100.0 / 1000.0,极少数 0.5 / 1.25 等特殊变比)。 + +### 用量计算 + +``` +consumption = (current_reading - previous_reading) × multiplier +``` + +例:三相表上月 280,本月 308,multiplier=10: + +``` +consumption = (308 - 280) × 10 = 280 度 +``` + +业户账单按"280 度"算,不是"28 度"。 + +## 第 2 层:阶梯计价(Tiered Pricing) + +### 用途 + +水电气**阶梯递增**:用得越多,单价越高。鼓励节约,符合国家政策。 + +例:某市水阶梯计价: + +| 阶梯 | 月用水(吨)| 单价 | +|---|---|---| +| 第一阶梯 | 0-20 | 3.0 元/吨 | +| 第二阶梯 | 21-30 | 4.5 元/吨 | +| 第三阶梯 | 30 以上 | 6.0 元/吨 | + +业户本月用 35 吨: + +| 段 | 用量 | 单价 | 段金额 | +|---|---|---|---| +| 0-20 吨(第一阶梯) | 20 | 3.0 | 60 | +| 21-30 吨(第二阶梯) | 10 | 4.5 | 45 | +| 31-35 吨(第三阶梯) | 5 | 6.0 | 30 | +| **合计** | **35** | | **135** | + +### Progressive 累进 vs Full-tier 简陋实现 + +> [!info] 本系统采用 **progressive 累进**(正确算法),不是 full-tier 简陋实现。 + +| 算法 | 35 吨水 | +|---|---| +| **Progressive 累进**(本系统)| 20 × 3 + 10 × 4.5 + 5 × 6 = 135 元 ✅ | +| Full-tier 简陋(错)| 35 × 6 = 210 元 ❌(整月用量按最高阶梯计)| +| Mixed(更错)| 35 × 4.5 = 157.5 元 ❌ | + +progressive 是国家政策标准做法,简陋实现是市场上劣质系统的常见 bug。`MeterBillCalculator::calculateTiered()` 实现得对,所以我们的账单准确。 + +### RatePlan + RateTier 模型 + +阶梯定义在 `RatePlan` + `RateTier` 模型里(不属于本子模块,在更通用的费率模块): + +``` +RatePlan ─── RateTier (1..n) + ├── tier=1, lower=0, upper=20, unit_price=3.0 + ├── tier=2, lower=20, upper=30, unit_price=4.5 + └── tier=3, lower=30, upper=null, unit_price=6.0 +``` + +`fee_type_id`(在 Meter 上)指向 RatePlan,Calculator 拿到 RatePlan → 按 tier 累加段金额。 + +## 第 3 层:min / max 封顶 + +### 用途 + +防止**极端用量**导致离谱账单: + +| 场景 | 问题 | 封顶方案 | +|---|---|---| +| 业户家漏水,1 月用 1000 吨 | 按阶梯算 ~ 6000+ 元 | `max=2000` 封顶,差额走维修保险 | +| 表故障读 0 度 | 业户不付钱了 | `min=20` 兜底(至少收基础费)| +| 业户家整月没人(零用量)| 看物业政策 | `min=20` 仍要收(物业服务费性质)| + +### 实现 + +`RatePlan` 上有 `min_amount` / `max_amount` 字段: + +``` +final_amount = max(min_amount, min(calculated_amount, max_amount)) +``` + +例:计算出 ¥6,000 但 `max_amount=2000` → 实际收 ¥2,000,差额 ¥4,000 由物业 / 维修保险承担(账面体现为"封顶减免")。 + +### 业务上的取舍 + +> [!warning] 封顶不是万能的 +> max 封顶让业户感激,但物业要承担差额。建议: +> +> - 设合理的 max(覆盖正常波动范围) +> - 异常用量先排查([[exception-high-consumption]])再决定是否减免 +> - 封顶降低收入,需评估对物业财务可持续性影响 + +min 类似: + +> [!info] min 的政策意义 +> min 通常对应"管网维护费 / 基础服务费",即使零用量也要分摊管网成本。但在国家政策严格的地区,要明确告知业户"min 是什么"。 + +## 完整算法流程 + +```mermaid +flowchart TD + A[抄表 current_reading] --> B[查询上次 reading] + B --> C[计算 consumption =
(current - previous) × multiplier] + C --> D[加载 RatePlan + RateTier] + D --> E[Calculator.calculateTiered
按阶梯累加段金额] + E --> F{有 min/max?} + F -->|有 max & 金额超| G[封顶到 max] + F -->|有 min & 金额低| H[补到 min] + F -->|正常范围| I[原值] + G --> J[最终账单金额] + H --> J + I --> J +``` + +## 完整算例 + +电费阶梯(假设): + +| 阶梯 | 用电度数 | 单价 | +|---|---|---| +| 1 | 0-200 | 0.5 | +| 2 | 200-400 | 0.6 | +| 3 | 400+ | 0.8 | + +`min_amount=10`,`max_amount=500`。 + +工业表(三相,multiplier=10),5 月抄表 308,上月 280: + +``` +1. consumption = (308 - 280) × 10 = 280 度 +2. 阶梯:200 × 0.5 + 80 × 0.6 = 100 + 48 = 148 元 +3. 封顶:10 ≤ 148 ≤ 500 → 不动 +4. 最终账单:148 元 +``` + +家用表(multiplier=1),5 月抄表 1100,上月 800: + +``` +1. consumption = (1100 - 800) × 1 = 300 度 +2. 阶梯:200 × 0.5 + 100 × 0.6 = 100 + 60 = 160 元 +3. 封顶:10 ≤ 160 ≤ 500 → 不动 +4. 最终账单:160 元 +``` + +漏水 case(家用表,水阶梯,multiplier=1),本月用 1000 吨: + +``` +1. consumption = 1000 吨 +2. 阶梯:20 × 3 + 10 × 4.5 + 970 × 6 = 60 + 45 + 5820 = 5925 元 +3. 封顶:5925 > max_amount(假设 2000)→ 封到 2000 +4. 最终账单:2000 元(差额 3925 由物业 / 维修保险承担) +``` + +## 业务人员视角 + +- **配置阶梯**:在 RatePlan + RateTier 模型里设置(不在 meter 子模块,通常运营 / 财务总监来设) +- **配置倍率**:建表时 / 后续编辑表时填(`MeterForm`,默认 1) +- **核对账单**:看到异常金额时,顺着 Calculator 算法手工验算 + +## 财务视角 + +- 阶梯计价 = 政策合规 +- 倍率确保工业表账单正确 +- min/max 封顶 = 风险控制 + 业户友好(max) + +## 业户视角 + +业户**不需要懂这套算法**,看到的是账单金额。但**对账时**要能理解: + +- 上月读数 → 本月读数 → 用量(度 / 吨 / 立方米) +- 用量 → 阶梯单价 → 金额 +- 如有封顶 → 凭证显示 + +## 相关文档 + +- [[meter-vs-meter-reading]] +- [[bill-generation-pipeline]] +- [[generate-bill-tiered-pricing]] +- [[generate-bill-with-multiplier]] +- [[generate-bill-min-max-cap]] +- [[exception-high-consumption]] diff --git a/prop-acc/concepts/meter/reading-source-and-photo-proof.md b/prop-acc/concepts/meter/reading-source-and-photo-proof.md new file mode 100644 index 0000000..db93e93 --- /dev/null +++ b/prop-acc/concepts/meter/reading-source-and-photo-proof.md @@ -0,0 +1,206 @@ +--- +title: prop-acc · meter · 抄表来源与拍照存证 +aliases: + - 抄表来源 + - 抄表拍照存证 + - manual vs remote + - MeterReadingSource +tags: + - 概念 + - prop-acc + - 计量表 + - 数据字典 +audience: + - 业务人员 + - 抄表员 + - 财务 +status: 已发布 +sub_feature: meter +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 抄表来源与拍照存证 + +每条 `MeterReading` 有 **`source` 字段**标记**抄表数据来源**(`manual` 手抄 vs `remote` 集抄),配合 **`photo_url`** 拍照存证,保证抄表数据**可追溯、可核对、有凭据**。 + +## `MeterReadingSource` 枚举 + +```php +enum MeterReadingSource: string +{ + case Manual = 'manual'; // 抄表员手动录入 + case Remote = 'remote'; // 集抄系统(IoT)自动上报 +} +``` + +只有两种,简洁明了。 + +## 两种来源对照 + +| 维度 | `manual`(手抄)| `remote`(集抄 / IoT)| +|---|---|---| +| 触发 | 抄表员到现场读表 + 录入 | 物联网集抄系统定时推送 | +| 数据流 | 抄表员手机 / 抄表机 → App / 后台 | IoT 网关 → 后端 API → 系统 | +| 速度 | 慢(几天到几周完成全社区)| 快(几小时全社区)| +| 准确性 | 中(可能手抖 / 看错)| 高(设备直接传)| +| 拍照存证 | **推荐**(`photo_url` 必填或强烈推荐)| 自动 | +| 操作员(`operated_by`)| 必填(抄表员 ID)| null 或系统账号 | +| 业务上常见 | 中小社区 / 老旧设备 | 新建社区 / 升级改造后 | +| 故障率 | 抄表员漏抄、误抄、不录入 | IoT 设备掉线、数据丢失 | + +## 拍照存证(`photo_url`) + +### 为什么要拍照 + +抄表是物业**收业户钱**的依据。万一业户质疑账单金额,物业要拿出证据"5 月 X 日,您家表头读数确实是 280 度,这是抄表当天的照片"。 + +> [!info] 真实情境 +> 张阿姨 5 月电费账单 ¥168(280 度),她声称"我家不可能用这么多",要看证据。 +> +> 物业打开后台 → 找 5 月那条 reading → 看 `photo_url` → 给业户看现场拍的表头照片("280" 清晰可见,旁边日期戳)→ **业户无话可说**。 + +### 实施细节 + +| 字段 | 实施 | +|---|---| +| `photo_url` | 存储到对象存储(S3 / 阿里云 OSS / 本地)的 URL | +| 上传时机 | 抄表 App 录入读数同时上传(强制)| +| 上传时机(批量导入)| Excel 批量导入时**没有拍照** → 业务上要求抄表员当场拍 + 单独留存,导入时只录数字 | +| 上传时机(remote)| 集抄无拍照(IoT 设备本身就是凭证)| +| 数据保留 | 法律 / 业务规定保留期(通常 3-5 年),与账单同周期 | + +### 业务流程上的强制度 + +| 物业政策 | 实施 | +|---|---| +| **必须拍照**(严)| `MeterReadingsRelationManager` Form 上 `photo` 字段标 `->required()`,无照片不能提交 | +| **建议拍照**(中)| Form 上 `photo` 可空,UI 上有"建议拍照"提示 | +| **不强制**(松)| Form 上 `photo` 可空,无任何提示 | + +当前实现**看具体配置**(应该是建议拍照,可空)。生产环境**强烈推荐"必须拍照"**,避免后续举证困难。 + +## 集抄(remote)的对接 + +### 数据流 + +```mermaid +sequenceDiagram + participant Meter[物理表] + participant Gateway[IoT 网关] + participant Backend[第三方集抄平台] + participant API[本系统 API] + participant DB + + Note over Meter: 物理表通过 RS485 / NB-IoT 等连网关 + + loop 定时(每天/每月) + Meter->>Gateway: 读数推送 + Gateway->>Backend: 上传(GSM/4G/有线) + Backend->>API: HTTP POST(批量推 reading) + API->>DB: 建 MeterReading(source=remote) + end +``` + +### 触发账单生成 + +集抄推数后**通常立即触发账单生成**(详见 [[bill-generation-pipeline]]): + +- 集抄 API 接收 → 写 MeterReading → 同事务内或立即触发 `GenerateBillsFromMeterReadingsAction` +- 或者每月固定时间批量(避免账单生成时机不一致) + +### 集抄掉线的兜底 + +| 集抄掉线 | 处置 | +|---|---| +| 个别表掉线(少数)| 抄表员补抄(`source=manual` + 备注"集抄掉线")| +| 大面积掉线(网关故障)| 集抄运维介入 + 物业短期手抄兜底 + 排查恢复 | +| 长期掉线(>2 周)| 重新评估集抄设备的可靠性 | + +## 手动抄表的实施 + +### 工具 + +- 抄表员手机 App(物业自研 / 第三方) +- 老式:纸质本子 + 后台输入(误差大,逐步淘汰) + +### 流程 + +```mermaid +flowchart TD + A[抄表员到现场] --> B[读表头] + B --> C[拍照] + C --> D[App 上录入
读数 + 上传照片] + D --> E[同步到后台] + E --> F[业务人员审核 / 直接生成账单] +``` + +### 操作员追踪 + +每条 `MeterReading` 有 `operated_by` 字段(抄表员 ID): + +- 业户对账单有疑问 → 后台查 → 看是谁抄的 +- 抄表员考核:本月抄了多少表、有无遗漏 +- 异常追责:某抄表员的读数频繁错 → 培训 / 换人 + +## 业务人员视角 + +后台 `ViewMeter` → 看 Reading 列表(`MeterReadingsRelationManager`),每条 reading 显示: + +| 列 | 内容 | +|---|---| +| 抄表日期 | `read_at` | +| 读数 | `current_reading` | +| 用量 | `consumption` | +| 来源 | `manual` / `remote`(图标区分)| +| 抄表员 | `operated_by`(`manual` 时显示)| +| 拍照 | `photo_url`(有图标,点开看照片)| +| 备注 | `memo` | + +## 抄表员视角 + +抄表员李师傅每月 25-30 号集中抄表: + +- 拿手机 App 出门 +- 按片区(楼栋)走,App 自动按楼层 / 房号给清单 +- 每家:开门(若有人)→ 找表 → 读数 → 拍照 → 录入 +- 数据**实时上传**(网络可用时)或**离线缓存**(无网时回去再传) +- 当月所有表抄完 → App 显示"完成度 100%" → 提交 + +每天 KPI:60-100 户(看小区密度)。 + +## 业户视角 + +业户**通常感知不到抄表细节**,只看月底账单。对账单有异议时: + +- 联系物业询问 +- 物业出示 `photo_url` 证据 +- 如果证据充分 → 业户认可 +- 如果证据不足(无照片 + 抄表员说不清)→ 物业可能减免 / 让业户支付平均月用量 + +## 系统视角:不可变 + 双锁 + +详见 [[meter-vs-meter-reading]] "两者的契约" + [[decommission-and-locking]] "已生成 Bill 的 Reading 锁定" 段。 + +- MeterReading 一经创建**不可改** +- 一旦生成 Bill 后**更不可改**(Policy 双锁) +- 任何错误走"作废 Bill → 改 Reading → 重生成 Bill"的组合流程(详见 [[exception-readings-locked-after-bill]]) + +## 待补 / 已知问题 + +| 项 | 状态 | +|---|---| +| 集抄回调签名校验 / 防重放 | 未文档化(可能已实现,看具体集成 | +| 抄表照片的对象存储清理(留存期满)| 未实现,需求看物业法务定 | +| 抄表员位置 / 时间戳防作弊(GPS + 时间)| 未实现,部分物业有此需求 | +| 拍照强制 + AI 识别照片内读数比对录入 | 未实现,高大上但实施成本高 | + +## 相关文档 + +- [[meter-vs-meter-reading]] +- [[bill-generation-pipeline]] +- [[decommission-and-locking]] +- [[read-single-meter-manual]] +- [[read-batch-via-excel-import]] +- [[read-via-iot-remote-source]] +- [[read-with-photo-proof]] diff --git a/prop-acc/concepts/meter/replacement-chain.md b/prop-acc/concepts/meter/replacement-chain.md new file mode 100644 index 0000000..b687d3f --- /dev/null +++ b/prop-acc/concepts/meter/replacement-chain.md @@ -0,0 +1,205 @@ +--- +title: prop-acc · meter · 表更换链 +aliases: + - 表更换链 + - Meter Replacement Chain + - replaced_meter_id + - R1 R2 后缀 +tags: + - 概念 + - prop-acc + - 计量表 + - 数据模型 +audience: + - 业务人员 + - 抄表员 + - 架构师 +status: 已发布 +sub_feature: meter +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 表更换链 + +物理表会**老化、损坏、定期校验**。旧表换新表后,系统通过 **`replaced_meter_id`** 字段把新表指回旧表,**初始读数继承**(避免业户被白嫖一段用量)。新表编号自动加 **`-R1` / `-R2` / ...** 后缀,**整条更换链**(代代相承)在数据库里可追溯。 + +## 为什么要更换链 + +> [!info] 真实情境 +> 张阿姨家电表(编号 E-501)用了 8 年,2026 年 5 月 物业例行校验发现读数跳变(怀疑表内电路老化),要换新表。 +> +> 换表那天: +> - 旧表 E-501 最后读数 = 5,000 度 +> - 新表 E-501-R1 出厂 = 0 度,但**初始读数继承 5,000 度** +> - 否则:新表从 0 起 → 业户下个月看着账单(假设用了 50 度)→ 但系统算的 "5050 - 0 = 5050 度"全要业户付 → 灾难 + +更换链保证**用量计算连续**,业户感知无差异。 + +## 数据模型 + +### 字段关系 + +```mermaid +flowchart LR + A[Meter 旧表
E-501
final_reading=5000
is_active=false
decommissioned_at=2026-05-15
decommission_reason=Replaced] -->|被 replaced_meter_id 指回| B[Meter 新表
E-501-R1
initial_reading=5000
is_active=true
installed_at=2026-05-15
replaced_meter_id=旧表 ID] +``` + +### 字段语义 + +| Meter 字段 | 旧表(被换)| 新表(替代)| +|---|---|---| +| `code` | E-501 | **E-501-R1**(`nextReplacementCode()` 自动算)| +| `is_active` | **false** | true | +| `installed_at` | 8 年前 | 2026-05-15(换表当天)| +| `decommissioned_at` | **2026-05-15** | null | +| `decommission_reason` | `Replaced`(枚举,见 [[decommission-and-locking]])| null | +| `final_reading` | **5000**(换表前最后读数)| null | +| `initial_reading` | (历史值,不动)| **5000**(继承自旧表)| +| `replaced_meter_id` | null(无上一代)| **旧表 ID** | + +## 换表后的抄表数据 + +```mermaid +flowchart TD + A[E-501 最后 reading
2026-04-30 → 5000 度] --> B[换表 2026-05-15] + B --> C[E-501-R1 第一次抄表
2026-05-31 → ?] + + C --> D[计算 5月用量] + D --> E{用量公式} + E -->|新表上累计读数| F[假设新表读到 50 度
但 initial_reading=5000
所以 current = 5050] + F --> G[consumption = (5050 - 5000) * multiplier = 50 度] +``` + +**关键**:新表 `initial_reading=5000` **不是**抄表的起点,而是用于计算用量的**基准**。下次抄表时: + +``` +current_reading(新表表头读数 + initial_reading)= 50 + 5000 = 5050 +previous_reading = 5000(继承自旧表最后读数) +consumption = (current - previous) × multiplier = (5050 - 5000) × 1 = 50 +``` + +业户付的就是 5 月实际用的 50 度,不是 5050。 + +> [!warning] 抄表员要注意 +> 抄表系统**显示给抄表员的数字是物理表头的数字**(50),系统**内部存的是叠加值**(5050)。如果系统设计不一致(让抄表员录 5050),会让人困惑。当前实现需查 `MeterReadingsRelationManager` / `MeterReadingsImporter` 看具体如何处理。 + +## 整条链的追溯 + +一张表可能多次换: + +``` +E-501 (原生) → E-501-R1 (第一次换) → E-501-R2 (第二次换) → E-501-R3 (第三次换) +``` + +每张表 `replaced_meter_id` 指上一代。后台 / API 可以: + +```php +// 找当前在役表 +$current = Meter::where('asset_id', $assetId) + ->where('fee_type_id', $feeType) + ->where('is_active', true) + ->first(); + +// 顺着链向上追溯 +$predecessors = []; +$cursor = $current; +while ($cursor->replaced_meter_id) { + $cursor = Meter::find($cursor->replaced_meter_id); + $predecessors[] = $cursor; +} +// $predecessors 现在是 [R2, R1, 原生] 的逆序数组 +``` + +业务上可用于: + +- 业户对历史用量有异议:看哪张表抄出来的 +- 表更换历史报表 +- 长期累计用量 + +## `nextReplacementCode()` 实现 + +代码 `Meter::nextReplacementCode($oldMeterCode)` 算法: + +```php +// 伪代码 +function nextReplacementCode(string $oldCode): string +{ + // E-501 → E-501-R1 + // E-501-R1 → E-501-R2 + // E-501-R2 → E-501-R3 + if (preg_match('/^(.*)-R(\d+)$/', $oldCode, $matches)) { + return $matches[1] . '-R' . ($matches[2] + 1); + } + return $oldCode . '-R1'; +} +``` + +业务人员**不需要手动想**新表编号,系统自动算。 + +## 操作:`ReplaceMeterAction` + +后台 → 计量表 → 找旧表 → 进 `ViewMeter` → 点 `ReplaceMeterAction`。 + +Modal 表单: + +| 字段 | 填什么 | +|---|---| +| **旧表最后读数(`final_reading`)** | 现场拍照确认,如 `5000` | +| **退役原因(`decommission_reason`)** | 选 `Replaced`(其他选项见 [[decommission-and-locking]])| +| **退役日期** | 默认今天 | +| **新表编号** | 自动 `E-501-R1`(可改但不推荐)| +| **新表 multiplier** | 默认继承旧表(可改)| +| **新表安装日期** | 默认今天 | +| 备注 | "校验未通过,换新表" | + +提交后系统在一个事务内: + +1. 旧表 `is_active=false`, `decommissioned_at=今天`, `decommission_reason=Replaced`, `final_reading=5000` +2. 建新表 `is_active=true`, `installed_at=今天`, `replaced_meter_id=旧表.id`, `initial_reading=5000`, `multiplier=继承` + +## 常见问题 + +> [!question] 旧表读数比新表初始低,会发生吗? +> 不会。新表的 `initial_reading` 就是旧表的 `final_reading`,逻辑上必然相等。 + +> [!question] 换表时业户家正在用电怎么处理? +> 实际换表过程要断电断水短暂时间,业户可感知。系统层面: +> +> - 旧表 `decommissioned_at` 和新表 `installed_at` 都填换表那天 +> - 中间用电量(几分钟到几小时)的微小差异通常忽略 +> - 严格的物业可在换表说明里告知业户"换表过程几分钟用电不计费" + +> [!question] 换表后旧表的历史 MeterReading 还能查吗? +> 能。每条 reading 都关联 `meter_id`(旧表 ID),不会因换表丢失。审计可完整追溯。 + +> [!question] 误换表(其实不该换)能撤销吗? +> 不能直接撤销(MeterReading 不可变,Meter 状态也不轻易回滚)。要修复: +> +> - 物理上把新表退役(`is_active=false`, `decommission_reason=Removed`) +> - 把旧表重启(`is_active=true`, `decommissioned_at=null`)→ 但这种"复活"操作在 Policy 层可能被守护拒绝([[decommission-and-locking]]) +> +> **预防胜于补救**:换表前确认。 + +> [!question] 同一张表换好多次,链很长怎么办? +> 链长本身不是问题,系统正常处理。如果链过长(>10 代),通常说明该表频繁出问题,业务上应: +> +> - 排查表的型号 / 安装环境 +> - 考虑改型号 / 换品牌 +> - 长链不影响数据查询性能(关联查询逐级递归,但物业表数量通常不大) + +> [!question] 新表 `multiplier` 与旧表不同可以吗? +> 可以,但**强烈不推荐**。如果新换的表倍率不同(例如旧 1x → 新 10x),用量计算公式就变,容易让业户困惑。除非业务上有明确升级原因(从普通家用表换成工业表),否则**继承旧表 multiplier**。 + +## 异常分支 + +- 表损坏(非校验)→ 走 [[replace-broken-meter]] 场景(meter_decommission_reason=Damaged) +- 不换表只退役 → [[decommission-without-replacement]] + +## 相关文档 + +- [[meter-vs-meter-reading]] +- [[decommission-and-locking]] +- [[multiplier-and-tiered-pricing]] +- [[replace-broken-meter]] +- [[decommission-without-replacement]]