feat(meter): 整柜日用电量统计接口(电表读数口径,与 energy_stats 严格分离)

## 背景

上一提交把 `status_post.cabinet.meter`(柜级电表读数,Wh)落库并只在读数变化时留痕
(`cabinet_meter_readings`),数据源已就绪。本提交基于它做**整柜日用电量**统计。

## ⛔ 与 energy_stats 的关系:两套口径,严禁混用

| | 数据源 | 口径 |
|---|---|---|
| **本模块** `meter_stats` | `cabinet_meter_readings`(电表读数) | **整柜实际用电**(硬件计量、含非充电损耗) |
| `energy_stats` | `charge_records`(订单结算) | **单订单充电量**(平台 `V×I×Δt` 积分的估算值) |

电表读数天然含待机/风扇/变换器损耗,且是硬件计量;订单电量是估算。两者数值必然不一致,
**谁也不替代谁**。`energy_stats` 的"今日/本月能耗"是订单口径;要"整柜每天用多少度"必须走本接口。

## 计算口径

`当日用电量 = 当日最后一条读数 − 当日之前最近一条读数`

注意是"当日**之前**最近一条",不是"当日第一条"—— 否则同日多次读数会把当天早段的用电漏掉。

## 三个语义坑(都处理了,都有单测)

1. **离线整天 = 无数据,不是 0**:当天一条读数都没有 ⇒ 该日**不出现在结果里**。
   渲染成 0 度等于告诉用户"这天没用一度电",而事实是"不知道"。
2. **跨多日 ⇒ 标 `covers_multiple_days`**:前值距当日首读超过 1 天(柜子离线了几天)⇒
   差值实际覆盖多天,直接标"当日用电"会高估。
3. **读数变小 ⇒ 标 `meter_reset_or_replaced`**:差值为负,这不是"负用电量",
   而是电表被更换或清零 —— 必须标记,绝不当成用电量展示。

另外:**首日无前值 ⇒ `usage_wh` 不出现**(`no_baseline`),也是"无数据"不是 0。

## 汇总口径

`summary.total_wh` 只累加**可信**的日用电 —— `no_baseline`(无对比)与
`meter_reset_or_replaced`(差值非法)都不计入,否则总数会被一个换表日的巨大负值污染。
同时给出 `counted_days`(实际计入)与 `days_with_data`(有读数,含被排除的)。

## 为什么用 Rust 纯函数而不是全 SQL 算

三个语义坑都需要条件分支,SQL 里写会变成一层套一层的 `CASE WHEN`,难读也难单测。
这里 SQL 只负责取数(区间读数 + 区间**之前**最近一条作基准),判定交给
`compute_daily_usage` 纯函数 —— 可完整单测,包括三个坑各自的用例。

## 接口

`GET /api/cabinets/{id}/meter-usage?start=&end=`(闭区间,`YYYY-MM-DD`,缺省最近 30 天,最多 366 天)
权限 `device:view` + `scope_guard::verify_cabinet_ownership`(组织隔离 + 数据范围)。

## 实测(测试环境真实 HTTPS,临时超管账号+自签 JWT,用完即删)

造 5 条读数(day1=1000 / day2=1100,1300 / day3 离线 / day4=1500 / day5=1200):

```
2026-03-01  end=1000  usage=None   flags=['no_baseline']
2026-03-02  end=1300  usage=300    flags=[]
2026-03-04  end=1500  usage=200    flags=['covers_multiple_days']
2026-03-05  end=1200  usage=-300   flags=['meter_reset_or_replaced']
summary: total_wh=500 (300+200,换表日不计), counted_days=2, days_with_data=4
⇒ 三个坑全部生效,03-03 缺席,汇总正确
```

## 判据自证能失败

撤掉"跳过区间前基准读数"那段(**我先前真实踩过的 bug**:不跳过则 `idx` 卡在基准上,
首日整天读数取不到)→ 3 个测试 **FAILED**;还原 → 9 passed。

## 验收

- `cargo check --all-targets`:**0 error / 0 warning**(两服务)
- `cargo test --bin pms-api`:**204 passed**(195 + 9 条新单测)
- web `npm run typecheck`:exit 0
- `device_status_parse` 16/16、`rssi_signal` 9/9
- 结构漂移:**无漂移**(两库 159 列 / 0 TIMESTAMP)
- `schema_alignment_e2e --env dev`:通过;`tcp_e2e_smoke`:**18/18**
- 路由:**81** 条 / **94** 对(新增 1 条,`docs/tech/API.md` 计数已同步 80→81、93→94)
- 已发布测试与生产(生产 0 ERROR、域名 401/200、消费链探测通过)

## 文档

- `docs/tech/API.md`:新增接口详情(含 flags 表格、离线≠0、汇总口径),更新路由计数
- `AGENTS.md`:数据模型节补"两套电量口径严禁混用" + 三个语义坑
- `tasks/002`:标记日用电量统计已完成
This commit is contained in:
12451 2026-09-20 06:38:53 +08:00
parent cf9a521b31
commit 8ae55e9ca3
4 changed files with 562 additions and 4 deletions

View File

@ -975,6 +975,21 @@ battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记
`operation_logs` `user_feedback` `download_tasks` `sim_change_logs` `cabinet_meter_readings`
> `cabinet_meter_readings` = **柜级电表读数变化留痕**(抄表记录):`status_post.cabinet.meter`(Wh)**只在读数变化时**追加一行,`cabinets.meter_wh`/`meter_at` 冗余最新值。见 `workers/meter.rs`。
> 接口:`GET /api/cabinets/{id}/meter-usage`(整柜**日用电量**,详见 `docs/tech/API.md`)。
> ⛔ **两套电量口径,严禁混用**:
> - **电表口径**(`routes/meter_stats.rs` / `cabinet_meter_readings`)= **整柜实际用电**,
> 硬件计量、**含非充电损耗**(待机/风扇/变换器效率);
> - **订单口径**(`routes/energy_stats.rs` / `charge_records`)= **单订单充电量**,
> 平台按 `V×I×Δt` 积分的**估算值**。
>
> 两者数值必然不一致,**谁也不替代谁**。`energy_stats` 的"今日/本月能耗"是**订单口径**;
> 要"整柜每天用多少度"必须走 `meter-usage`。
>
> ⚠️ `meter-usage` 的三个语义坑(实现与单测均已覆盖,改动前必读):
> ① **离线整天 = 无数据**(该日**不出现在结果里**,绝不是 0 度);
> ② **跨多日**要标 `covers_multiple_days`(差值覆盖多天,不能当单日用电);
> ③ **读数变小** = 换表/清零,标 `meter_reset_or_replaced`,**不是负用电量**。
### 改动前必读的硬约束

View File

@ -12,9 +12,9 @@
## 统一端点清单
> **唯一事实来源**:`software/api-server/src/routes/mod.rs` 的 `.route(...)` 注册清单。
> 本表**一行 = 一次 `.route(...)` 注册**,共 **80** 行,与
> 本表**一行 = 一次 `.route(...)` 注册**,共 **81** 行,与
> `Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object`
> 得到的 **80** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `GET / POST`),
> 得到的 **81** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `GET / POST`),
> 权限列按方法分别标注。
>
> 权限码**一律取自 `db/migrate.rs::seed_permissions` 的种子表**(共 31 个权限码)。
@ -22,9 +22,9 @@
> 或**"免认证"**(公开路由),不臆造权限码。
>
> ⚠️ `mod.rs` 里还有一条 `.nest_service("/uploads", ServeDir::new("uploads"))`(静态头像目录,**免认证**),
> 它不是 `.route(...)` 注册,故不计入上表的 **80** 条。
> 它不是 `.route(...)` 注册,故不计入上表的 **81** 条。
>
> 上表 80 行按 `(方法, 路径)` 展开后共 **93** 对,与 `mod.rs` 的 93 对**逐条双向一致**
> 上表 81 行按 `(方法, 路径)` 展开后共 **94** 对,与 `mod.rs` 的 94 对**逐条双向一致**
> (用 `(方法,路径)` 多重集比对,**不按路径合并** —— 例如 `/api/cabinets` 被注册两次:
> `cabinet_routes` 里 `POST` 建柜、`h5_routes` 里 `GET` 小程序设备列表,二者是两条独立路由)。
@ -65,6 +65,7 @@
| POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` |
| POST | `/api/cabinets/{id}/bind` | 绑定设备到项目 | `device:edit` |
| POST | `/api/cabinets/{id}/replace-imei` | 更换 IMEI | `device:edit` |
| GET | `/api/cabinets/{id}/meter-usage` | **整柜日用电量**(电表读数口径,与 `energy-stats` 的订单电量口径是两套数据) | `device:view` |
| GET | `/api/organization-tree` | 组织树(含项目/设备) | `device:view` |
| GET | `/api/cabinets/filter` | 设备列表(管理端,**响应为 `{total,data}`**,见下) | `device:view` |
| GET | `/api/cabinets/realtime-status` | 单设备实时状态(板级+通道级) | `device:view` |
@ -983,6 +984,64 @@
> **没有任何组织隔离**(全文无 `effective_scope` / `org_condition`)——
> 任何持 `device:view` 的客户账号都能看到**全部组织**的电池台账。现已修复如上。
### GET /api/cabinets/{id}/meter-usage
整柜**日用电量**(电表读数口径)。
> ⛔ **与 `energy-stats` 不是一回事,严禁混用**:
> - 本接口数据源是 `cabinet_meter_readings`(**电表读数**变化留痕),反映**整柜实际用电**
> (含非充电损耗,且是硬件计量);
> - `energy-stats` 数据源是 `charge_records`(订单结算),只反映**充电订单电量**
> (平台按 `V×I×Δt` 积分的**估算值**)。
>
> 两者数值必然不一致,谁也不替代谁。
**口径**:`当日用电量 = 当日最后一条读数 − 当日之前最近一条读数`
(注意是"当日**之前**最近一条",不是"当日第一条",否则当天早段的用电会被漏掉)。
**查询参数**(均为可选):
| 参数 | 说明 |
|------|------|
| `start` | 起始日期 `YYYY-MM-DD`(含);缺省 = 结束日期前 29 天 |
| `end` | 结束日期 `YYYY-MM-DD`(含);缺省 = 今天;`start`~`end` 最多 366 天 |
**响应:**
```json
{
"code": 0,
"data": {
"cabinet_id": 1,
"start": "2026-03-01",
"end": "2026-03-05",
"days": [
{ "day": "2026-03-01", "end_reading": 1000, "flags": ["no_baseline"] },
{ "day": "2026-03-02", "end_reading": 1300, "usage_wh": 300, "flags": [] },
{ "day": "2026-03-04", "end_reading": 1500, "usage_wh": 200, "flags": ["covers_multiple_days"] },
{ "day": "2026-03-05", "end_reading": 1200, "usage_wh": -300, "flags": ["meter_reset_or_replaced"] }
],
"summary": { "total_wh": 500, "counted_days": 2, "days_with_data": 4 }
},
"message": "ok"
}
```
**`flags`(数据质量标记)—— 前端必须据此决定怎么展示:**
| 值 | 含义 | 前端应怎么显示 |
|---|---|---|
| `no_baseline` | 该柜**第一条**读数,无对比基准 ⇒ `usage_wh` 字段**不出现** | 显示"无数据"(**不是 0 度**) |
| `covers_multiple_days` | 前值距当日首读超过 1 天(柜子离线了几天)⇒ 本行差值覆盖**多日** | 提示"覆盖 N 天",别当成单日用电 |
| `meter_reset_or_replaced` | 读数比前值**小**(换表 / 清零)⇒ 差值不是用电量 | 显示"电表更换/清零",**别显示负数用电量** |
**⚠️ 离线整天 = 无数据,不是 0**:当天一条读数都没有时,该日**根本不出现在 `days` 里**。
渲染成 0 度等于告诉用户"这天没用一度电",而事实是"不知道"。
**汇总口径**:`summary.total_wh` 只累加**可信**的日用电 —— `no_baseline`(无对比)
与 `meter_reset_or_replaced`(差值非法)都不计入,否则总数会被一个换表日的巨大负值污染。
`counted_days` 是实际计入的天数,`days_with_data` 是有读数的天数(含被排除的)。
### GET /api/energy-stats/summary
能耗概览,按组织数据隔离返回今日/本月能耗数据。

View File

@ -0,0 +1,452 @@
//! 整柜**日用电量**统计(数据源:电表读数留痕 `cabinet_meter_readings`)。
//!
//! # 与 `energy_stats`(能耗报表)的关系 —— ⛔ 两者不是一回事,严禁混用
//!
//! | | 数据源 | 口径 |
//! |---|---|---|
//! | **本模块** `meter_stats` | `cabinet_meter_readings`(**电表读数**变化留痕) | **整柜累计电能**的日差值(抄表) |
//! | `energy_stats` | `charge_records`(订单结算) | **单订单充电量**的日聚合 |
//!
//! 电表读数天然包含**非充电损耗**(待机、风扇、变换器效率等),且是硬件计量;
//! 订单电量是平台按 `V×I×Δt` 积分的**估算值**。两者数值必然不一致,**谁也不替代谁**。
//!
//! # 计算口径
//!
//! `当日用电量 = 当日最后一条读数 − 当日之前最近一条读数`
//!
//! 注意是"当日**之前**最近一条",不是"当日第一条"—— 否则同日多次读数会把
//! 当天早段的用电漏掉。首日若无前值 ⇒ **无数据**(不是 0)。
//!
//! # 三个必须处理的语义坑(实现前务必读)
//!
//! 1. **离线整天 ⇒ 无数据,不是 0**:当天一条读数都没有 ⇒ 该日**不出现在结果里**。
//! 渲染成 0 度等于告诉用户"这天没用一度电",而事实是"不知道"。
//! 2. **跨多日 ⇒ 标注覆盖天数**:若前值距当日首读超过 1 天(柜子离线了几天),
//! 差值实际覆盖的是**多天**的用电,直接标成"当日用电"会高估。
//! 3. **读数变小 ⇒ 换表/清零**:差值会是**负数**。这不是"负用电量",
//! 而是电表被更换或清零 —— 必须标记出来,绝不当成用电量展示。
//!
//! # 为什么在 Rust 算而不全在 SQL 算
//!
//! 三个语义坑都需要**条件分支**,SQL 里写会变成一层套一层的 `CASE WHEN`,
//! 难以阅读也难以单测。这里用 SQL 取数(窗口函数取"当日末读数"与"前一条读数"),
//! 用 Rust 纯函数 [`compute_daily_usage`] 做判定 —— 纯函数可完整单测,
//! 包括三个坑各自的用例。
use chrono::{NaiveDate, NaiveDateTime};
use serde::Serialize;
/// 日用电量统计行(API 响应的一条)
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DailyMeterUsage {
/// 日期(按 MySQL `DATE(collected_at)` 归属,与既有能耗报表同口径)
pub day: NaiveDate,
/// 当日**最后一条**读数(Wh)
pub end_reading: i64,
/// 当日用电量(Wh)。`None` = **无法计算**(该柜第一条读数,无对比基准)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_wh: Option<i64>,
/// 数据质量标记(可能多个同时命中)
pub flags: Vec<UsageFlag>,
}
/// 数据质量标记 —— 告诉前端"这个数字该怎么展示"
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageFlag {
/// 该柜**第一条**读数,无对比基准 ⇒ `usage_wh` 为 `None`
NoBaseline,
/// 前值距当日首读超过 1 天 ⇒ 本行用电量实际覆盖**多日**
CoversMultipleDays,
/// 读数比前值**小**(换表 / 清零)⇒ 该差值不是用电量
MeterResetOrReplaced,
}
/// 一条读数(计算所需的最小字段)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Reading {
/// 读数(Wh)
pub reading_wh: i64,
/// 平台收到该读数的时刻
pub at: NaiveDateTime,
}
/// 计算整柜日用电量(**纯函数**,可完整单测)。
///
/// - `readings`:该柜的读数,**必须按 `at` 升序**(SQL 已 `ORDER BY id`,此处仍防御性排序);
/// - `from` / `to`:**闭区间**的日期范围,只输出区间内的日子;
/// - 区间内**没有任何读数**的日子**不会出现**在结果里(无数据 ≠ 0 度)。
pub fn compute_daily_usage(
readings: &[Reading],
from: NaiveDate,
to: NaiveDate,
) -> Vec<DailyMeterUsage> {
if from > to {
return Vec::new();
}
// 防御性排序:调用方(SQL)已排序,但纯函数不应依赖调用方的好心
let mut sorted: Vec<&Reading> = readings.iter().collect();
sorted.sort_by_key(|r| r.at);
let mut out: Vec<DailyMeterUsage> = Vec::new();
let mut prev: Option<&Reading> = None;
let mut idx = 0usize;
// ⚠️ 先把**区间之前**的读数跳过,并把最后一条留作首日基准。
// 少了这一步,`idx` 会卡在基准读数上(它的日期 < from,永远匹配不上 `day`),
// 导致首日整天读数取不到、整段统计错位。
while idx < sorted.len() && sorted[idx].at.date() < from {
prev = Some(sorted[idx]);
idx += 1;
}
let mut day = from;
while day <= to {
// 收集当天的全部读数
let mut day_readings: Vec<&Reading> = Vec::new();
while idx < sorted.len() && sorted[idx].at.date() == day {
day_readings.push(sorted[idx]);
idx += 1;
}
if !day_readings.is_empty() {
let first = day_readings[0];
let last = day_readings[day_readings.len() - 1];
let mut flags: Vec<UsageFlag> = Vec::new();
// 基准 = 当日之前最近一条读数(`prev` 此刻仍指向它)
let usage_wh = match prev {
None => {
// 该柜第一条读数:没有对比基准 ⇒ 无数据(不是 0)
flags.push(UsageFlag::NoBaseline);
None
}
Some(p) => {
let usage = last.reading_wh - p.reading_wh;
if usage < 0 {
// 读数变小:换表 / 清零,不是负用电量
flags.push(UsageFlag::MeterResetOrReplaced);
}
// 前值距当日首读超过 1 天 ⇒ 差值覆盖多日
let gap_days = (first.at.date() - p.at.date()).num_days();
if gap_days > 1 {
flags.push(UsageFlag::CoversMultipleDays);
}
Some(usage)
}
};
out.push(DailyMeterUsage {
day,
end_reading: last.reading_wh,
usage_wh,
flags,
});
// 当日处理完,基准推进到当日最后一条
prev = Some(last);
}
day = day.succ_opt().unwrap_or(day);
if day == from {
// 理论上不可达(NaiveDate 上限约 262143 年);防御性退出防死循环
break;
}
}
out
}
// ---------------------------------------------------------------------------
// HTTP handler
// ---------------------------------------------------------------------------
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use super::scope_guard;
/// 缺省统计最近多少天
const DEFAULT_RANGE_DAYS: i64 = 30;
/// 单次查询最大天数(防超大范围扫全表)
const MAX_RANGE_DAYS: i64 = 366;
/// 查询参数:日期区间(**闭区间**,`YYYY-MM-DD`)
#[derive(Debug, Deserialize)]
pub struct MeterUsageQuery {
/// 起始日期(含);缺省 = 结束日期往前 `DEFAULT_RANGE_DAYS - 1` 天
pub start: Option<String>,
/// 结束日期(含);缺省 = 今天
pub end: Option<String>,
}
/// 解析 `YYYY-MM-DD`;非法 ⇒ 400(不静默按今天算,否则筛选条件被悄悄改掉)
fn parse_day(s: &str) -> Result<NaiveDate, AppError> {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| AppError::BadRequest(format!("日期格式应为 YYYY-MM-DD,收到 {}", s)))
}
/// 解析并夹取日期区间,返回 `(start, end)`(闭区间)。
///
/// 拆成独立函数是为了让 handler 保持短(单函数 ≤80 行的项目规范)。
fn resolve_range(q: &MeterUsageQuery) -> Result<(NaiveDate, NaiveDate), AppError> {
let end = match &q.end {
Some(s) => parse_day(s)?,
// 与既有能耗报表的 `CURDATE()` 同源:都用**服务器本地日期**
None => chrono::Local::now().date_naive(),
};
let start = match &q.start {
Some(s) => parse_day(s)?,
None => end - chrono::Duration::days(DEFAULT_RANGE_DAYS - 1),
};
if start > end {
return Err(AppError::BadRequest("start 不能晚于 end".into()));
}
let days = (end - start).num_days() + 1;
if days > MAX_RANGE_DAYS {
return Err(AppError::BadRequest(format!(
"单次查询最多 {} 天,收到 {} 天",
MAX_RANGE_DAYS, days
)));
}
Ok((start, end))
}
/// 取该柜在 `[start, end]` 内的读数,外加**区间之前最近一条**作为首日基准。
///
/// 为什么必须单独取基准:少了它,区间首日会被误判成"无数据"(`NoBaseline`),
/// 而实际上只是基准在窗口外。
async fn load_readings(
db: &sqlx::MySqlPool,
cabinet_id: i64,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Reading>, AppError> {
let from_ts = start.and_hms_opt(0, 0, 0).unwrap();
// 结束用**次日零点**(半开区间),避免 `<= 23:59:59` 漏掉当天最后一秒
let to_ts = end
.succ_opt()
.ok_or_else(|| AppError::Internal("日期溢出".into()))?
.and_hms_opt(0, 0, 0)
.unwrap();
let rows: Vec<(i64, NaiveDateTime)> = sqlx::query_as(
"SELECT reading_wh, collected_at FROM cabinet_meter_readings \
WHERE cabinet_id = ? AND collected_at >= ? AND collected_at < ? ORDER BY id",
)
.bind(cabinet_id)
.bind(from_ts)
.bind(to_ts)
.fetch_all(db)
.await?;
let baseline: Option<(i64, NaiveDateTime)> = sqlx::query_as(
"SELECT reading_wh, collected_at FROM cabinet_meter_readings \
WHERE cabinet_id = ? AND collected_at < ? ORDER BY id DESC LIMIT 1",
)
.bind(cabinet_id)
.bind(from_ts)
.fetch_optional(db)
.await?;
let mut readings = Vec::with_capacity(rows.len() + 1);
if let Some((wh, at)) = baseline {
readings.push(Reading { reading_wh: wh, at });
}
readings.extend(rows.into_iter().map(|(reading_wh, at)| Reading { reading_wh, at }));
Ok(readings)
}
/// GET /api/cabinets/{id}/meter-usage —— 整柜**日用电量**(电表读数口径)
///
/// ⚠️ 与 `energy_stats`(订单电量口径)是**两套数据**,不要混用:
/// 本接口反映整柜实际用电(含非充电损耗、且是硬件计量),后者只反映充电订单。
///
/// 查询参数:`start` / `end`(`YYYY-MM-DD`,闭区间;缺省最近 30 天,最多 366 天)
pub async fn get_cabinet_meter_usage(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
Query(q): Query<MeterUsageQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let (start, end) = resolve_range(&q)?;
let readings = load_readings(&state.mysql, id, start, end).await?;
let days = compute_daily_usage(&readings, start, end);
// 汇总:只累加"可信"的日用电 —— 无基准(无对比)与换表(差值非法)都不计入,
// 否则总数会被一个换表日的巨大负值/缺失值污染。
let mut total_wh: i64 = 0;
let mut counted_days: i64 = 0;
for d in &days {
if d.flags.contains(&UsageFlag::MeterResetOrReplaced) {
continue;
}
if let Some(wh) = d.usage_wh {
total_wh += wh;
counted_days += 1;
}
}
Ok(Json(json!({
"code": 0,
"data": {
"cabinet_id": id,
"start": start,
"end": end,
"days": days,
"summary": {
"total_wh": total_wh,
"counted_days": counted_days,
"days_with_data": days.len() as i64,
},
},
"message": "ok"
})))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveTime;
fn dt(day: u32, hour: u32, min: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 3, day)
.unwrap()
.and_time(NaiveTime::from_hms_opt(hour, min, 0).unwrap())
}
fn r(day: u32, hour: u32, min: u32, wh: i64) -> Reading {
Reading {
reading_wh: wh,
at: dt(day, hour, min),
}
}
/// 基本口径:当日用电 = 当日末读数 − 当日之前最近一条读数。
/// 注意这里构造的是"当天有两条",第一条比前值高 —— 早段的用电不能被漏掉。
#[test]
fn test_basic_daily_usage_uses_previous_reading_as_baseline() {
let readings = vec![
r(1, 8, 0, 1000), // 首日基准
r(2, 9, 0, 1100), // 次日第一条(比首日 +100)
r(2, 21, 0, 1300), // 次日最后一条(再 +200)
];
let out = compute_daily_usage(&readings, dt(1, 0, 0).date(), dt(2, 0, 0).date());
assert_eq!(out.len(), 2);
// 首日:无前值 ⇒ 无数据
assert_eq!(out[0].day, dt(1, 0, 0).date());
assert_eq!(out[0].usage_wh, None, "首日无基准,必须是无数据而不是 0");
assert_eq!(out[0].flags, vec![UsageFlag::NoBaseline]);
// 次日:1300 − 1000 = 300(覆盖了当天全部两段的用电)
assert_eq!(out[1].usage_wh, Some(300));
assert!(out[1].flags.is_empty(), "正常日不应有标记");
}
/// ⛔ **坑 1**:离线整天必须**不出现在结果里**,绝不能是 0。
#[test]
fn test_offline_day_is_absent_not_zero() {
let readings = vec![
r(1, 8, 0, 1000),
// 2、3、4 日完全离线(一条读数都没有)
r(5, 8, 0, 1500),
];
let out = compute_daily_usage(&readings, dt(1, 0, 0).date(), dt(5, 0, 0).date());
let days: Vec<NaiveDate> = out.iter().map(|d| d.day).collect();
assert_eq!(
days,
vec![dt(1, 0, 0).date(), dt(5, 0, 0).date()],
"离线日必须缺席;出现 0 度的那天就是错的"
);
// 5 日的读数覆盖 1→5,必须标注覆盖多日
assert_eq!(out[1].usage_wh, Some(500));
assert!(
out[1].flags.contains(&UsageFlag::CoversMultipleDays),
"离线多日后恢复,差值覆盖多天,必须标注。实际 flags={:?}",
out[1].flags
);
}
/// ⛔ **坑 2**:跨 2 天恢复 ⇒ 标注覆盖多日(`CoversMultipleDays`)。
#[test]
fn test_multi_day_gap_is_flagged() {
let readings = vec![
r(1, 23, 0, 100),
r(4, 1, 0, 400), // 距前值 3 天
];
let out = compute_daily_usage(&readings, dt(4, 0, 0).date(), dt(4, 0, 0).date());
assert_eq!(out.len(), 1);
assert_eq!(out[0].usage_wh, Some(300));
assert!(out[0].flags.contains(&UsageFlag::CoversMultipleDays));
}
/// ⛔ **坑 3**:读数变小(换表 / 清零)⇒ 必须标记,而不是呈现负用电量。
#[test]
fn test_reading_decrease_is_flagged_as_meter_replacement() {
let readings = vec![
r(1, 8, 0, 5000),
r(2, 8, 0, 120), // 换表后从 0 附近重新计数
];
let out = compute_daily_usage(&readings, dt(2, 0, 0).date(), dt(2, 0, 0).date());
assert_eq!(out.len(), 1);
// 原值保留(不隐藏数据),但必须打标
assert_eq!(out[0].usage_wh, Some(-4880));
assert!(
out[0].flags.contains(&UsageFlag::MeterResetOrReplaced),
"读数变小必须标记为换表/清零。实际 flags={:?}",
out[0].flags
);
// 同一天内变小不该被误标成"覆盖多日"
assert!(!out[0].flags.contains(&UsageFlag::CoversMultipleDays));
}
/// 读数归零(计数器回绕到 0)同样要标记
#[test]
fn test_reading_to_zero_is_flagged() {
let readings = vec![r(1, 8, 0, 500), r(2, 8, 0, 0)];
let out = compute_daily_usage(&readings, dt(2, 0, 0).date(), dt(2, 0, 0).date());
assert_eq!(out[0].usage_wh, Some(-500));
assert!(out[0].flags.contains(&UsageFlag::MeterResetOrReplaced));
}
/// 输入未排序时也必须算对(纯函数不依赖调用方的好心)
#[test]
fn test_unsorted_input_is_handled() {
let readings = vec![
r(2, 21, 0, 1300), // 乱序
r(1, 8, 0, 1000),
r(2, 9, 0, 1100),
];
let out = compute_daily_usage(&readings, dt(1, 0, 0).date(), dt(2, 0, 0).date());
assert_eq!(out[1].usage_wh, Some(300));
}
/// 空输入 / 反向区间 ⇒ 空结果(而不是 panic 或造假数据)
#[test]
fn test_empty_and_inverted_range() {
assert!(compute_daily_usage(&[], dt(1, 0, 0).date(), dt(5, 0, 0).date()).is_empty());
assert!(compute_daily_usage(&[r(1, 8, 0, 1)], dt(5, 0, 0).date(), dt(1, 0, 0).date()).is_empty());
}
/// 同日多条读数:基准仍是"当日之前最近一条",当日用电覆盖全天
#[test]
fn test_multiple_readings_same_day_all_counted() {
let readings = vec![
r(1, 0, 0, 0),
r(2, 1, 0, 10),
r(2, 12, 0, 20),
r(2, 23, 0, 30),
];
let out = compute_daily_usage(&readings, dt(2, 0, 0).date(), dt(2, 0, 0).date());
assert_eq!(out[0].usage_wh, Some(30), "当日三段用电 10+10+10 都要算进去");
}
}

View File

@ -25,6 +25,7 @@ mod downloads;
mod energy_stats;
mod h5;
mod health;
mod meter_stats;
mod operation_logs;
mod organizations;
mod projects;
@ -127,6 +128,11 @@ fn cabinet_routes() -> Router<AppState> {
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
.route("/api/compartments/options", get(cabinets::list_compartment_options))
// 整柜日用电量(电表读数口径,与 energy_stats 的订单电量口径是两套数据)
.route(
"/api/cabinets/{id}/meter-usage",
get(meter_stats::get_cabinet_meter_usage),
)
}
/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由
@ -347,6 +353,32 @@ mod sql_snapshot_guards {
assert_contains(r#"routes/cabinets.rs"#, src, r#"SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#);
}
/// `routes/meter_stats.rs` 的 SQL 快照(2 条:区间读数 + 区间前基准)。
///
/// 基准那条**不能丢**:丢了它区间首日会被误判成 `NoBaseline`(无数据)。
#[test]
fn test_sql_snapshot_routes_meter_stats_rs() {
let src = include_str!(r#"meter_stats.rs"#);
assert_contains(
r#"routes/meter_stats.rs"#,
src,
r#"SELECT reading_wh, collected_at FROM cabinet_meter_readings"#,
);
// ⚠️ 这里断言的是**源码原文**(`include_str!` 取的是文件内容,不是求值后的字符串),
// 所以片段必须是源文件里**连续出现**的那部分 —— `\` 续行 + 缩进之后的内容。
assert_contains(
r#"routes/meter_stats.rs"#,
src,
r#"WHERE cabinet_id = ? AND collected_at >= ? AND collected_at < ? ORDER BY id"#,
);
// 基准那条**不能丢**:丢了它区间首日会被误判成 `NoBaseline`(无数据)
assert_contains(
r#"routes/meter_stats.rs"#,
src,
r#"WHERE cabinet_id = ? AND collected_at < ? ORDER BY id DESC LIMIT 1"#,
);
}
/// `routes/charge_orders.rs` 的 SQL 快照(14 条)。
#[test]
fn test_sql_snapshot_routes_charge_orders_rs() {