295 lines
9.8 KiB
Markdown
295 lines
9.8 KiB
Markdown
---
|
|
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 层<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 后台**几乎感受不到**这三层:
|
|
|
|
- 看到的就是 `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"分层借鉴)
|