diff --git a/AGENTS.md b/AGENTS.md index e794767..e6d1050 100644 --- a/AGENTS.md +++ b/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`,**不是负用电量**。 ### 改动前必读的硬约束 diff --git a/docs/tech/API.md b/docs/tech/API.md index fe5cc85..9b2f3f5 100644 --- a/docs/tech/API.md +++ b/docs/tech/API.md @@ -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 能耗概览,按组织数据隔离返回今日/本月能耗数据。 diff --git a/software/api-server/src/routes/meter_stats.rs b/software/api-server/src/routes/meter_stats.rs new file mode 100644 index 0000000..c0cb055 --- /dev/null +++ b/software/api-server/src/routes/meter_stats.rs @@ -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, + /// 数据质量标记(可能多个同时命中) + pub flags: Vec, +} + +/// 数据质量标记 —— 告诉前端"这个数字该怎么展示" +#[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 { + 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 = 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 = 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, + /// 结束日期(含);缺省 = 今天 + pub end: Option, +} + +/// 解析 `YYYY-MM-DD`;非法 ⇒ 400(不静默按今天算,否则筛选条件被悄悄改掉) +fn parse_day(s: &str) -> Result { + 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, 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, + Path(id): Path, + Query(q): Query, +) -> Result, 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 = 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 都要算进去"); + } +} diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index 32ca4b2..e1ca372 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -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 { .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() {