--- 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"分层借鉴)