Files
uniprop-manual/prop-acc/concepts/meter/bill-generation-pipeline.md
2026-05-25 23:53:01 +08:00

9.8 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 · 账单生成的三层分层
账单生成 pipeline
Calculator Service Action 三层
MeterBillCalculator
MeterBillGenerationService
GenerateBillsFromMeterReadingsAction
概念
prop-acc
计量表
架构
计费
架构师
业务人员
已发布 meter 2026-05-25 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 + 事件

调用栈

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(纯算)

// 伪代码示意
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(业务编排)

// 伪代码示意
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(入口)

// 伪代码示意
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 模块一开始就是这样设计的,所以没经历这种痛。

测试金字塔

flowchart TD
  A[Action 层<br/>少数 Feature 测试] --> B[Service 层<br/>中等数 Feature 测试]
  B --> C[Calculator 层<br/>大量 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 后台几乎感受不到这三层:

  • 看到的就是 ViewMeterListMeters 上的"生成账单"按钮
  • 点击 → 弹出选择 reading 的 Modal → 提交
  • 系统报告"X 张账单已生成"
  • 失败的 reading 列出原因

底层的 Calculator / Service / Action 对业务人员透明。

架构师视角

这套分层是评审新功能的参考:

新需求 该改哪一层
加新计价算法(累退 / 二次方等) Calculator 层(纯算)
加业户关联规则(房屋多业主时按比例分摊) Service 层(业务编排)
加新触发场景(微信小程序业户主动结账) Action 层(入口)+ 新 controller
改账单字段(加新字段、改 due_at 计算) Service 层
加批量优化(并行 / 队列) Action 层

每层都是单一职责,改动局部、不会污染其他层。

相关文档