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:
parent
cf9a521b31
commit
8ae55e9ca3
15
AGENTS.md
15
AGENTS.md
@ -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`,**不是负用电量**。
|
||||
|
||||
### 改动前必读的硬约束
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
能耗概览,按组织数据隔离返回今日/本月能耗数据。
|
||||
|
||||
452
software/api-server/src/routes/meter_stats.rs
Normal file
452
software/api-server/src/routes/meter_stats.rs
Normal 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 都要算进去");
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user