2026-07-02 05:38:01 +08:00
//! 能耗管理 API — 总览、排行、趋势、导出
//!
2026-09-20 02:44:51 +08:00
//! **数据源:`charge_records` 实时聚合**(不再读写 `energy_stats` 表)。
//!
//! `energy_stats` 是"按天快照"的汇总表,但全仓**没有任何写入方**(无 INSERT/REPLACE) ,
//! 生产与测试库该表恒为 0 行 → 原先的 summary / 柜排名 / 项目统计 / 趋势 / 导出**全部恒为 0**
//! (客户侧表现为"没用电")。真实数据源是 `charge_records`:每次充电结束由订单结算
//! 写入一条(含 `cabinet_id` / `compartment_id` / `start_time` / `end_time` / `energy` / `max_temperature` / `status`)。
//! 因此这里统一改为**按时间范围 + 组织隔离直接聚合 `charge_records`**。
//!
//! 口径说明(详见 [`time_window`] 与 `charge_records` 的 DDL 注释):
//! - 时间字段统一用 `start_time`(不是 `end_time`):与 `h5.rs` 的今日统计
//! ( `DATE(cr.start_time) = CURDATE()`)以及"充电记录"列表的时间筛选口径一致,
//! 且每笔充电记录**只归属它开始的那一天**,不会因跨天而当天重复计数;
//! - `energy` 单位为 **Wh**( `workers/report_worker.rs` 结算时写入 `energy::mwh_to_wh(mwh)`) ;
//! - 电费估算按 ¥0.80/kWh 计算:`Wh × 80(分/kWh) / 1000 / 100` = 元。
//!
//! `energy_stats` 表**保留不删**(历史语义/回滚余地),但本模块已完全不再引用它。
2026-07-02 05:38:01 +08:00
use axum ::extract ::{ Query , State } ;
use axum ::Json ;
use serde ::Deserialize ;
use serde_json ::{ json , Value } ;
use crate ::error ::AppError ;
use crate ::middleware ::auth ::{ self , CurrentUser } ;
2026-09-20 04:31:42 +08:00
use super ::cabinet_tree ;
2026-09-19 23:40:46 +08:00
/// 电费单价, 0.80 元/kWh = 80 分 / 1000 Wh
const COST_PER_WH_CENT : i64 = 80 ;
2026-07-02 05:38:01 +08:00
2026-09-20 02:44:51 +08:00
/// 趋势查询 `range`(天数)的合法区间上限,防超大值拖垮查询
const TREND_RANGE_MAX : i64 = 3650 ;
/// 趋势查询 `range` 的合法区间下限(至少 1 天)
const TREND_RANGE_MIN : i64 = 1 ;
/// 导出明细单次最大行数(防止无上限导出把内存/Excel 撑爆)
const EXPORT_MAX_ROWS : i64 = 100_000 ;
/// 聚合表达式:`energy` 列在 pms 是 BIGINT、在 pms_dev 是 DECIMAL,
/// 统一 `CAST(... AS SIGNED)` 后交给 Rust `i64`; `COALESCE` 保证无数据时为 0 而非 NULL。
const SUM_ENERGY_SIGNED : & str = " CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) " ;
/// 聚合表达式:充电次数。`COUNT(*)` 原生类型是 BIGINT UNSIGNED,
/// 不能直接解码成 `i64`( sqlx 类型不匹配),必须 CAST。
const COUNT_CHARGES_SIGNED : & str = " CAST(COUNT(*) AS SIGNED) " ;
/// 能耗聚合的时间窗口 SQL 片段(全部按 `cr.start_time` 半开区间 `[起, 止)`)。
///
/// # 为什么用半开区间而不是 `DATE(cr.start_time) = CURDATE()`
/// `DATE(列) = 常量` 会让 `start_time` 上的索引失效(列被函数包裹 ⇒ 无法走 range scan) ,
/// 而 `cr.start_time >= 起 AND cr.start_time < 止` 是**等价的**且能命中
/// `idx_charge_records_start_time`。时刻口径与 `DATE(...)` 完全一致:
/// 只按"充电开始的那一刻"归属,不用 `end_time`。
mod time_window {
/// 今日:`[今天 00:00:00, 明天 00:00:00)`
pub const TODAY : & str =
" cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY " ;
/// 本月:`[本月 1 日 00:00:00, 下月 1 日 00:00:00)`
///
/// 原 `energy_stats` 实现只有下界(`stat_date >= 本月1日`),因为 `stat_date` 是"按天快照"、
/// 不存在未来日期。换成时间戳列后补上上界:口径不变(仍只含本月),
/// 同时避免时钟偏移产生的未来行被算进本月。
pub const THIS_MONTH : & str = " cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
AND cr . start_time < DATE_FORMAT ( CURDATE ( ) + INTERVAL 1 MONTH , ' % Y - % m - 01 ' ) " ;
/// 导出用的"结束日期"(含当天):`end_date <= ?` 在 DATE 列上原本含当天,
/// 换成时间戳列后必须写成 `< 次日 00:00:00` 才是同样的"含当天"语义。
pub const BEFORE_NEXT_DAY : & str = " cr.start_time < DATE_ADD(?, INTERVAL 1 DAY) " ;
}
/// 把外部传入的 `range` 夹到合法区间,防负数 / 0 / 超大值
/// (负数会让 `DATE_SUB(..., INTERVAL -5 DAY)` 变成"未来 5 天",超大值会扫全表)。
fn clamp_range ( range : i64 ) -> i64 {
range . clamp ( TREND_RANGE_MIN , TREND_RANGE_MAX )
}
2026-07-02 05:38:01 +08:00
/// 趋势查询参数
#[ derive(Debug, Deserialize) ]
pub struct TrendQuery {
/// day | week | month
#[ serde(default = " default_granularity " ) ]
pub granularity : String ,
/// 天数范围, 默认30天
#[ serde(default = " default_range " ) ]
pub range : i64 ,
}
/// 导出查询参数
#[ derive(Debug, Deserialize) ]
pub struct ExportQuery {
pub cabinet_id : Option < i64 > ,
pub project_id : Option < i64 > ,
pub start_date : Option < String > ,
pub end_date : Option < String > ,
}
/// 柜子排行行
#[ derive(Debug, sqlx::FromRow) ]
struct CabinetRankRow {
id : i64 ,
abstract_id : String ,
name : Option < String > ,
2026-07-04 04:03:02 +08:00
today_energy : Option < i64 > ,
month_energy : Option < i64 > ,
2026-07-02 05:38:01 +08:00
}
/// 项目统计行
#[ derive(Debug, sqlx::FromRow) ]
struct ProjectStatRow {
id : i64 ,
name : String ,
cabinet_count : i64 ,
2026-07-04 04:03:02 +08:00
today_energy : Option < i64 > ,
month_energy : Option < i64 > ,
2026-07-02 05:38:01 +08:00
}
/// 趋势数据行
#[ derive(Debug, sqlx::FromRow) ]
struct TrendRow {
period : String , // 日期或周/月标识
2026-07-04 04:03:02 +08:00
energy : Option < i64 > ,
2026-07-02 05:38:01 +08:00
charge_count : Option < i64 > ,
}
/// 能耗明细行(用于导出)
#[ derive(Debug, sqlx::FromRow) ]
struct EnergyDetailRow {
stat_date : chrono ::NaiveDate ,
cabinet_id : i64 ,
cabinet_name : Option < String > ,
project_name : Option < String > ,
2026-07-04 04:03:02 +08:00
total_energy : Option < i64 > ,
2026-07-02 05:38:01 +08:00
charge_count : Option < i64 > ,
}
fn default_granularity ( ) -> String { " day " . to_string ( ) }
fn default_range ( ) -> i64 { 30 }
2026-09-20 02:44:51 +08:00
// ---------------------------------------------------------------------------
// 过滤条件构造(组织隔离 + 数据范围,两处口径必须完全一致)
// ---------------------------------------------------------------------------
/// `charge_records cr` 上的过滤片段:组织隔离 **∧** 数据范围(项目级)
///
/// - 未配置数据范围 ⇒ 只有组织条件(与今天完全一致);
/// - 无组织的客户侧账号 ⇒ ` AND 1=0`(空集,绝不退化成"全部") ;
/// - 总管理员 ⇒ 两个条件都为空(豁免)。
///
/// 返回的是**条件后缀**(调用方拼在 `WHERE {时间窗口}` 之后)⇒ 这里直接 `push_str`
/// 追加 ` AND ...`; **不能**用 `append_where`(空串时会补 `WHERE`,拼出 `WHERE a WHERE b`)。
fn charge_record_filters ( user : & CurrentUser ) -> ( String , Vec < String > ) {
let ( mut cond , mut binds ) : ( String , Vec < String > ) = if user . role_level > = 2 {
( String ::new ( ) , vec! [ ] )
} else if let Some ( oid ) = user . organization_id {
(
" AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?) " . to_string ( ) ,
vec! [ oid . to_string ( ) ] ,
)
} else {
( " AND 1=0 " . to_string ( ) , vec! [ ] )
} ;
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . cabinet_condition ( " cr.cabinet_id " ) ;
cond . push_str ( & scope_cond ) ;
binds . extend ( scope_binds ) ;
( cond , binds )
}
2026-09-20 04:31:42 +08:00
// ---------------------------------------------------------------------------
// 可见柜子 / 在线柜数(组织隔离 + 数据范围,两处口径必须完全一致)
// ---------------------------------------------------------------------------
/// 可见柜子(`cabinets c`)的过滤**条件后缀**:组织隔离 **∧** 数据范围(项目级)。
2026-09-20 02:44:51 +08:00
///
2026-09-20 04:31:42 +08:00
/// - 总管理员(`role_level >= 2`)⇒ 空串(豁免);
/// - 客户侧账号有组织 ⇒ ` AND p.organization_id = ?` + 项目级范围收窄;
/// - 客户侧账号**无组织** ⇒ ` AND 1=0`(空集,**绝不退化成"全部"**)。
///
/// ⚠️ 返回的是**条件后缀**(调用方自己起 `WHERE`),因此片段一律以 ` AND ` 开头;
/// 用 `1=0` 表达"空集"可以避免在 Rust 里为"无组织"再分叉一条分支。
fn visible_cabinet_filters ( user : & CurrentUser ) -> ( String , Vec < String > ) {
2026-09-20 02:44:51 +08:00
if user . role_level > = 2 {
2026-09-20 04:31:42 +08:00
return ( String ::new ( ) , vec! [ ] ) ;
2026-09-20 02:44:51 +08:00
}
let Some ( oid ) = user . organization_id else {
2026-09-20 04:31:42 +08:00
return ( " AND 1=0 " . to_string ( ) , vec! [ ] ) ;
2026-09-20 02:44:51 +08:00
} ;
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . project_condition ( " c.project_id " ) ;
2026-09-20 04:31:42 +08:00
// 绑定顺序必须与占位符出现顺序一致:先组织,再项目级范围
(
format! ( " AND p.organization_id = ? {} " , scope_cond ) ,
std ::iter ::once ( oid . to_string ( ) ) . chain ( scope_binds ) . collect ( ) ,
)
}
/// `cabinet_count`( **全部可见柜数**)的 SQL —— 语义冻结:只数柜子。
///
/// **不含任何 `status` 条件**: `cabinets.status` 是启用/禁用**配置态**,
/// 既不表示在线,也不该影响"柜子总数"(禁用中的柜子依然是柜子)。
fn visible_cabinets_count_sql ( user : & CurrentUser ) -> ( String , Vec < String > ) {
let ( cond , binds ) = visible_cabinet_filters ( user ) ;
let sql = if user . role_level > = 2 {
format! ( " SELECT COUNT(*) FROM cabinets c WHERE 1=1 {} " , cond )
} else {
format! (
" SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE 1=1{} " ,
cond
)
} ;
( sql , binds )
}
/// 在线统计的**取数 SQL**:只取可见柜的 IMEI( `imei IS NOT NULL`)。
///
/// # 为什么 DB 不参与"在线"判定
/// `cabinets.status` 是**启用/禁用配置态**(全仓唯一写入方
/// `routes/cabinets.rs::set_cabinet_status` 只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0( 离线) **)⇒ 按 `status = 1` 统计在线,会在**一台设备都没连**时
/// 依然返回"全部在线"。
///
/// 因此这里只负责按**组织隔离 + 数据范围**取出一批 IMEI, 在线与否交给
/// [`cabinet_tree::count_online_devices`]( Redis 一次 pipeline, 判据与设备列表
/// `is_online` 同源)。
fn visible_cabinet_imeis_sql ( user : & CurrentUser ) -> ( String , Vec < String > ) {
let ( cond , binds ) = visible_cabinet_filters ( user ) ;
let sql = if user . role_level > = 2 {
format! (
" SELECT c.imei FROM cabinets c WHERE c.imei IS NOT NULL{} " ,
cond
)
} else {
format! (
" SELECT c.imei FROM cabinets c JOIN projects p ON c.project_id = p.id \
WHERE c . imei IS NOT NULL { } " ,
cond
)
} ;
( sql , binds )
}
/// 执行「可见柜计数」SQL( 组织隔离 + 数据范围)
async fn count_visible_cabinets (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
) -> Result < i64 , AppError > {
let ( sql , binds ) = visible_cabinets_count_sql ( user ) ;
let mut query = sqlx ::query_scalar ::< _ , i64 > ( & sql ) ;
for bind in & binds {
2026-09-20 02:44:51 +08:00
query = query . bind ( bind ) ;
}
Ok ( query . fetch_one ( pool ) . await ? )
}
2026-09-20 04:31:42 +08:00
/// 取当前用户**可见柜子的 IMEI 列表**(组织隔离 + 数据范围,顺序无关)
///
/// `imei` 列可空(老数据/联调库有 NULL) , 按 `Option<String>` 解码后过滤,
/// 避免一行 NULL 让整个在线统计解码失败(接口 500) 。
async fn visible_cabinet_imeis (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
) -> Result < Vec < String > , AppError > {
let ( sql , binds ) = visible_cabinet_imeis_sql ( user ) ;
let mut query = sqlx ::query_scalar ::< _ , Option < String > > ( & sql ) ;
for bind in & binds {
query = query . bind ( bind ) ;
}
Ok ( query . fetch_all ( pool ) . await ? . into_iter ( ) . flatten ( ) . collect ( ) )
}
/// `online_count`: **在线柜数 = 可见柜里 Redis 明确在线的台数**。
///
/// 先按组织隔离 + 数据范围取出可见柜的 IMEI, 再用**一次 pipeline** 批量查
/// `device:{imei}.online`( **零 N 次单查**);判据是
/// [`cabinet_tree::is_device_online`] —— 与设备列表 `is_online` 同一纯函数,
/// 且**完全不看 `cabinets.status`**。
async fn count_online_cabinets (
state : & super ::AppState ,
user : & CurrentUser ,
) -> Result < i64 , AppError > {
let imeis = visible_cabinet_imeis ( & state . mysql , user ) . await ? ;
Ok ( cabinet_tree ::count_online_devices ( state , & imeis ) . await )
}
2026-07-02 05:38:01 +08:00
/// GET /api/energy-stats/summary — 总览
///
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
pub async fn get_summary (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " energy:view " ) ? ;
let pool = & state . mysql ;
2026-09-20 02:44:51 +08:00
// 今天/本月能耗的过滤条件 = 组织隔离 + 数据范围(项目级,未配置 = 不限制)
let ( org_filter , org_binds ) = charge_record_filters ( & user ) ;
2026-07-02 05:38:01 +08:00
2026-09-20 02:44:51 +08:00
// 今日总能耗:聚合 charge_records( 原来是 energy_stats.stat_date = CURDATE())
2026-07-02 05:38:01 +08:00
let today_sql = format! (
2026-09-20 02:44:51 +08:00
" SELECT {} FROM charge_records cr WHERE {}{} " ,
SUM_ENERGY_SIGNED ,
time_window ::TODAY ,
2026-07-02 05:38:01 +08:00
org_filter
) ;
2026-07-04 04:03:02 +08:00
let mut today_q = sqlx ::query_scalar ::< _ , i64 > ( & today_sql ) ;
2026-07-02 05:38:01 +08:00
for v in & org_binds { today_q = today_q . bind ( v ) ; }
2026-07-04 04:03:02 +08:00
let today_energy : i64 = today_q . fetch_one ( pool ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-09-20 02:44:51 +08:00
// 本月总能耗:聚合 charge_records( 原来是 energy_stats.stat_date >= 本月1日)
2026-07-02 05:38:01 +08:00
let month_sql = format! (
2026-09-20 02:44:51 +08:00
" SELECT {} FROM charge_records cr WHERE {}{} " ,
SUM_ENERGY_SIGNED ,
time_window ::THIS_MONTH ,
2026-07-02 05:38:01 +08:00
org_filter
) ;
2026-07-04 04:03:02 +08:00
let mut month_q = sqlx ::query_scalar ::< _ , i64 > ( & month_sql ) ;
2026-07-02 05:38:01 +08:00
for v in & org_binds { month_q = month_q . bind ( v ) ; }
2026-07-04 04:03:02 +08:00
let month_energy : i64 = month_q . fetch_one ( pool ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-09-20 04:31:42 +08:00
// 柜子总数(全部可见柜,语义不变)/ 在线柜子数(**以 Redis 为准**,与 `cabinets.status` 无关)
let cabinet_count = count_visible_cabinets ( pool , & user ) . await ? ;
let online_count = count_online_cabinets ( & state , & user ) . await ? ;
2026-07-02 05:38:01 +08:00
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
2026-07-04 04:03:02 +08:00
" today_energy " : today_energy ,
" month_energy " : month_energy ,
2026-09-19 23:40:46 +08:00
" estimated_cost " : ( month_energy * COST_PER_WH_CENT / 1000 ) as f64 / 100.0 ,
2026-07-02 05:38:01 +08:00
" cabinet_count " : cabinet_count ,
" online_count " : online_count ,
} ,
" message " : " ok "
} ) ) )
}
/// GET /api/energy-stats/cabinets — 柜子能耗排行
///
/// 按今日能耗降序排列,返回柜子列表及今日/本月能耗。
pub async fn get_cabinet_ranking (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " energy:view " ) ? ;
let pool = & state . mysql ;
2026-09-20 02:44:51 +08:00
// 组织过滤: INNER JOIN projects 把可见范围限制在用户所属组织
// (口径与原实现完全一致,只是不再经过 energy_stats)
2026-07-02 05:38:01 +08:00
let ( org_join , org_binds ) : ( String , Vec < String > ) = if user . role_level > = 2 {
( String ::new ( ) , vec! [ ] )
} else if let Some ( oid ) = user . organization_id {
(
" JOIN projects p ON c.project_id = p.id AND p.organization_id = ? " . to_string ( ) ,
vec! [ oid . to_string ( ) ] ,
)
} else {
( " JOIN projects p ON 1=0 " . to_string ( ) , vec! [ ] )
} ;
2026-09-20 02:44:51 +08:00
// 数据范围(项目级)叠加:未配置 = 不限制;总管理员豁免。
// 这里是 **JOIN 片段后缀**(拼在 `FROM cabinets c {}` 位置),直接原样拼接 ` AND ...`。
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . project_condition ( " c.project_id " ) ;
let ( org_join , org_binds ) = {
let mut join = org_join ;
let mut binds = org_binds ;
join . push_str ( & scope_cond ) ;
binds . extend ( scope_binds ) ;
( join , binds )
} ;
// 今日/本月能耗改为「按 cabinet_id 聚合 charge_records」的两个派生表:
// 每个柜子至多一行, LEFT JOIN 到 cabinets 后语义与原 energy_stats 版本一致。
//
// `COALESCE(c.abstract_id, '')`: `abstract_id` 已被迁移改成**可空**(老数据/联调库里就有 NULL) ,
// 而 `CabinetRankRow.abstract_id` 是 `String` ⇒ 遇到 NULL 会让整条查询解码失败、接口 500。
// 响应字段名与类型不变(仍是字符串),只是把 NULL 显示为空串而不是让整个排行崩掉。
2026-07-02 05:38:01 +08:00
let sql = format! (
2026-09-20 02:44:51 +08:00
" SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name, \
CAST ( COALESCE ( cr_today . today_total , 0 ) AS SIGNED ) AS today_energy , \
CAST ( COALESCE ( cr_month . month_total , 0 ) AS SIGNED ) AS month_energy \
2026-07-02 05:38:01 +08:00
FROM cabinets c \
2026-09-19 23:40:46 +08:00
LEFT JOIN ( \
2026-09-20 02:44:51 +08:00
SELECT cr . cabinet_id , SUM ( cr . energy ) AS today_total \
FROM charge_records cr \
WHERE { today } \
GROUP BY cr . cabinet_id \
) cr_today ON c . id = cr_today . cabinet_id \
LEFT JOIN ( \
SELECT cr . cabinet_id , SUM ( cr . energy ) AS month_total \
FROM charge_records cr \
WHERE { month } \
GROUP BY cr . cabinet_id \
) cr_month ON c . id = cr_month . cabinet_id \
2026-07-02 05:38:01 +08:00
{ } \
ORDER BY today_energy DESC , c . id ASC " ,
2026-09-20 02:44:51 +08:00
org_join ,
today = time_window ::TODAY ,
month = time_window ::THIS_MONTH ,
2026-07-02 05:38:01 +08:00
) ;
let mut query = sqlx ::query_as ::< _ , CabinetRankRow > ( & sql ) ;
for v in & org_binds { query = query . bind ( v ) ; }
let rows = query . fetch_all ( pool ) . await ? ;
let list : Vec < Value > = rows . iter ( ) . map ( | r | {
json! ( {
" cabinet_id " : r . id ,
" abstract_id " : r . abstract_id ,
" name " : r . name ,
2026-07-04 04:03:02 +08:00
" today_energy " : r . today_energy . unwrap_or ( 0 ) ,
" month_energy " : r . month_energy . unwrap_or ( 0 ) ,
2026-07-02 05:38:01 +08:00
} )
} ) . collect ( ) ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : list ,
" message " : " ok "
} ) ) )
}
/// GET /api/energy-stats/projects — 项目能耗统计
///
/// 按今日能耗降序排列,返回项目级聚合数据。
pub async fn get_project_stats (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " energy:view " ) ? ;
let pool = & state . mysql ;
let ( org_filter , org_binds ) : ( String , Vec < String > ) = if user . role_level > = 2 {
( String ::new ( ) , vec! [ ] )
} else if let Some ( oid ) = user . organization_id {
( " WHERE p.organization_id = ? " . to_string ( ) , vec! [ oid . to_string ( ) ] )
} else {
( " WHERE 1=0 " . to_string ( ) , vec! [ ] )
} ;
2026-09-20 02:44:51 +08:00
// 数据范围(项目级)叠加:只统计范围内项目的能耗(未配置 = 不限制)。
// 这里是 **WHERE 后缀**(拼在 `GROUP BY` 之前),直接原样拼接 ` AND ...`。
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . project_condition ( " p.id " ) ;
let ( org_filter , org_binds ) = {
let mut cond = org_filter ;
let mut binds = org_binds ;
cond . push_str ( & scope_cond ) ;
binds . extend ( scope_binds ) ;
( cond , binds )
} ;
// `charge_records` 只有 `cabinet_id`,项目维度必须经 cabinets → projects 关联;
// 两个派生表都**按 cabinet_id 聚合**(每柜一行),避免"记录级 JOIN 让 SUM 被放大 N 倍":
// 若直接 LEFT JOIN charge_records 再 GROUP BY 项目,则本月汇总会被今天的记录条数重复累加。
2026-07-02 05:38:01 +08:00
let sql = format! (
" SELECT p.id, p.name, \
COUNT ( DISTINCT c . id ) AS cabinet_count , \
2026-09-20 02:44:51 +08:00
CAST ( COALESCE ( SUM ( cr_today . today_total ) , 0 ) AS SIGNED ) AS today_energy , \
CAST ( COALESCE ( SUM ( cr_month . month_total ) , 0 ) AS SIGNED ) AS month_energy \
2026-07-02 05:38:01 +08:00
FROM projects p \
LEFT JOIN cabinets c ON p . id = c . project_id \
2026-09-19 23:40:46 +08:00
LEFT JOIN ( \
2026-09-20 02:44:51 +08:00
SELECT cr . cabinet_id , SUM ( cr . energy ) AS today_total \
FROM charge_records cr \
WHERE { today } \
GROUP BY cr . cabinet_id \
) cr_today ON c . id = cr_today . cabinet_id \
LEFT JOIN ( \
SELECT cr . cabinet_id , SUM ( cr . energy ) AS month_total \
FROM charge_records cr \
WHERE { month } \
GROUP BY cr . cabinet_id \
) cr_month ON c . id = cr_month . cabinet_id \
2026-07-02 05:38:01 +08:00
{ } \
GROUP BY p . id , p . name \
ORDER BY today_energy DESC , p . id ASC " ,
2026-09-20 02:44:51 +08:00
org_filter ,
today = time_window ::TODAY ,
month = time_window ::THIS_MONTH ,
2026-07-02 05:38:01 +08:00
) ;
let mut query = sqlx ::query_as ::< _ , ProjectStatRow > ( & sql ) ;
for v in & org_binds { query = query . bind ( v ) ; }
let rows = query . fetch_all ( pool ) . await ? ;
let list : Vec < Value > = rows . iter ( ) . map ( | r | {
json! ( {
" project_id " : r . id ,
" name " : r . name ,
" cabinet_count " : r . cabinet_count ,
2026-07-04 04:03:02 +08:00
" today_energy " : r . today_energy . unwrap_or ( 0 ) ,
" month_energy " : r . month_energy . unwrap_or ( 0 ) ,
2026-07-02 05:38:01 +08:00
} )
} ) . collect ( ) ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : list ,
" message " : " ok "
} ) ) )
}
/// GET /api/energy-stats/trend — 趋势数据
///
/// granularity: day | week | month, 默认 day
/// range: 天数范围, 默认 30
pub async fn get_trend (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
Query ( params ) : Query < TrendQuery > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " energy:view " ) ? ;
let pool = & state . mysql ;
2026-09-20 02:44:51 +08:00
// 组织过滤: cr.cabinet_id → cabinets.project_id → projects.organization_id
// (口径与原实现完全一致,仅把别名 es → cr) + 数据范围(项目级)
let ( org_filter , org_binds ) = charge_record_filters ( & user ) ;
// range 必须夹紧:负数会让 DATE_SUB 变成"未来 N 天",超大值会拖垮查询
let range = clamp_range ( params . range ) ;
// 前端的粒度取值只有 day / week / month( 见 software/web/src/pages/energy/index.tsx 的
// Select options) , 未知取值按 day 处理(与原实现一致,不报错)。
//
// 三档都改为「按 period 分组聚合 charge_records」:
// 原 day 分支没有 GROUP BY( 直接吐 energy_stats 的行),而换到 charge_records 后
// 一天会有多条记录,**必须按 DATE(start_time) 分组**,否则同一 period 会重复出现多行,
// 折线图/柱状图的 X 轴会重复。period 文本格式与原实现保持一致
// ( day = 'YYYY-MM-DD', week = '%Y-%u', month = '%Y-%m'),前端只把它当分类标签用。
let ( period_expr , group_expr ) = match params . granularity . as_str ( ) {
" week " = > ( " DATE_FORMAT(cr.start_time, '%Y-%u') " , " period " ) ,
" month " = > ( " DATE_FORMAT(cr.start_time, '%Y-%m') " , " period " ) ,
_ = > ( " DATE_FORMAT(cr.start_time, '%Y-%m-%d') " , " period " ) ,
2026-07-02 05:38:01 +08:00
} ;
2026-09-20 02:44:51 +08:00
let sql = format! (
" SELECT {} AS period, {} AS energy, {} AS charge_count \
FROM charge_records cr \
WHERE cr . start_time > = DATE_SUB ( CURDATE ( ) , INTERVAL ? DAY ) { } \
GROUP BY { } ORDER BY period ASC " ,
period_expr ,
SUM_ENERGY_SIGNED ,
COUNT_CHARGES_SIGNED ,
org_filter ,
group_expr
) ;
let mut q = sqlx ::query_as ::< _ , TrendRow > ( & sql ) . bind ( range ) ;
for v in & org_binds { q = q . bind ( v ) ; }
let rows = q . fetch_all ( pool ) . await ? ;
2026-07-02 05:38:01 +08:00
let list : Vec < Value > = rows . iter ( ) . map ( | r | {
json! ( {
" period " : r . period ,
2026-07-04 04:03:02 +08:00
" energy " : r . energy . unwrap_or ( 0 ) ,
2026-07-02 05:38:01 +08:00
" charge_count " : r . charge_count . unwrap_or ( 0 ) ,
} )
} ) . collect ( ) ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
" granularity " : params . granularity ,
" list " : list ,
} ,
" message " : " ok "
} ) ) )
}
/// GET /api/energy-stats/export — 异步导出能耗报表
pub async fn export_energy_stats (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
Query ( params ) : Query < ExportQuery > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " energy:export " ) ? ;
let user_id = user . user_id ;
let file_name = generate_export_filename ( & params ) ;
2026-09-20 02:44:51 +08:00
// 序列化查询参数(含组织过滤信息 + 数据范围)
2026-07-02 05:38:01 +08:00
let query_json = serde_json ::to_string ( & json! ( {
" cabinet_id " : params . cabinet_id ,
" project_id " : params . project_id ,
" start_date " : params . start_date ,
" end_date " : params . end_date ,
2026-09-19 23:40:46 +08:00
" org_id " : user . organization_id ,
" role_level " : user . role_level ,
2026-09-20 02:44:51 +08:00
// 数据范围(项目级白名单)必须随任务一起落库:导出在后台跑,
// 不带过去就会绕过 scope( 越权导出更大范围)
" scope_project_ids " : user . effective_scope ( ) . project_ids ,
2026-07-02 05:38:01 +08:00
} ) )
. unwrap_or_default ( ) ;
// 插入下载任务
let result = sqlx ::query (
" INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES ( ? , ? , 0 , ? , ? ) "
)
. bind ( user_id )
. bind ( " energy_stats " )
. bind ( & file_name )
. bind ( & query_json )
. execute ( & state . mysql )
. await ? ;
let task_id = result . last_insert_id ( ) as i64 ;
// 后台异步生成 Excel
let pool = state . mysql . clone ( ) ;
tokio ::spawn ( async move {
if let Err ( e ) = generate_energy_excel ( pool , task_id ) . await {
tracing ::error! ( " 导出能耗统计失败 task_id={}: {} " , task_id , e ) ;
}
} ) ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : { " task_id " : task_id } ,
" message " : " 导出任务已创建 "
} ) ) )
}
/// 生成导出文件名
fn generate_export_filename ( params : & ExportQuery ) -> String {
let start = params
. start_date
. as_ref ( )
. map ( | s | s . replace ( '-' , " " ) )
. unwrap_or_default ( ) ;
let end = params
. end_date
. as_ref ( )
. map ( | s | s . replace ( '-' , " " ) )
. unwrap_or_default ( ) ;
format! ( " 能耗统计_ {} - {} .xlsx " , start , end )
}
/// 构建导出条件
2026-09-20 02:44:51 +08:00
///
/// 列名口径:`cabinet_id` 由 `es.` 改为 `cr.`;日期由 `stat_date`( DATE 列)改为
/// `start_time`( TIMESTAMP 列),并保持「含当天」的语义(见 `time_window::BEFORE_NEXT_DAY`)。
2026-07-02 05:38:01 +08:00
fn build_export_clause (
cabinet_id : Option < i64 > ,
project_id : Option < i64 > ,
start_date : Option < & str > ,
end_date : Option < & str > ,
) -> ( String , Vec < String > ) {
let mut conditions = Vec ::new ( ) ;
let mut bind_values = Vec ::new ( ) ;
if let Some ( id ) = cabinet_id {
2026-09-20 02:44:51 +08:00
conditions . push ( " cr.cabinet_id = ? " ) ;
2026-07-02 05:38:01 +08:00
bind_values . push ( id . to_string ( ) ) ;
}
if let Some ( id ) = project_id {
conditions . push ( " c.project_id = ? " ) ;
bind_values . push ( id . to_string ( ) ) ;
}
if let Some ( d ) = start_date {
2026-09-20 02:44:51 +08:00
conditions . push ( " cr.start_time >= ? " ) ;
2026-07-02 05:38:01 +08:00
bind_values . push ( d . to_string ( ) ) ;
}
if let Some ( d ) = end_date {
2026-09-20 02:44:51 +08:00
conditions . push ( time_window ::BEFORE_NEXT_DAY ) ;
2026-07-02 05:38:01 +08:00
bind_values . push ( d . to_string ( ) ) ;
}
let clause = if conditions . is_empty ( ) {
String ::new ( )
} else {
format! ( " WHERE {} " , conditions . join ( " AND " ) )
} ;
( clause , bind_values )
}
2026-09-20 00:01:01 +08:00
/// 读取导出任务的文件名与查询参数
///
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task (
pool : & sqlx ::MySqlPool ,
2026-07-02 05:38:01 +08:00
task_id : i64 ,
2026-09-20 00:01:01 +08:00
) -> Result < ( String , Value ) , Box < dyn std ::error ::Error + Send + Sync > > {
2026-07-02 05:38:01 +08:00
let row : Option < ( String , String ) > = sqlx ::query_as (
" SELECT file_name, error_message FROM download_tasks WHERE id = ? "
)
. bind ( task_id )
2026-09-20 00:01:01 +08:00
. fetch_optional ( pool )
2026-07-02 05:38:01 +08:00
. await ? ;
let ( file_name , query_json ) = row . ok_or ( " 任务不存在 " ) ? ;
let params : Value = serde_json ::from_str ( & query_json ) . unwrap_or ( json! ( { } ) ) ;
2026-09-20 00:01:01 +08:00
Ok ( ( file_name , params ) )
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where ( params : & Value ) -> ( String , Vec < String > ) {
2026-09-19 23:40:46 +08:00
let ( mut where_clause , mut bind_values ) = build_export_clause (
2026-07-02 05:38:01 +08:00
params . get ( " cabinet_id " ) . and_then ( | v | v . as_i64 ( ) ) ,
params . get ( " project_id " ) . and_then ( | v | v . as_i64 ( ) ) ,
params . get ( " start_date " ) . and_then ( | v | v . as_str ( ) ) ,
params . get ( " end_date " ) . and_then ( | v | v . as_str ( ) ) ,
) ;
2026-09-19 23:40:46 +08:00
// 组织过滤(非总管理员)
let role_level = params . get ( " role_level " ) . and_then ( | v | v . as_i64 ( ) ) . unwrap_or ( 0 ) ;
let org_id = params . get ( " org_id " ) . and_then ( | v | v . as_i64 ( ) ) ;
if role_level < 2 {
if let Some ( oid ) = org_id {
let prefix = if where_clause . is_empty ( ) { " WHERE " } else { " AND " } ;
2026-09-20 02:44:51 +08:00
where_clause . push_str ( & format! ( " {} cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?) " , prefix ) ) ;
2026-09-19 23:40:46 +08:00
bind_values . push ( oid . to_string ( ) ) ;
} else {
let prefix = if where_clause . is_empty ( ) { " WHERE " } else { " AND " } ;
where_clause . push_str ( & format! ( " {} 1=0 " , prefix ) ) ;
}
}
2026-09-20 02:44:51 +08:00
// 数据范围(非总管理员):与在线查询同一套项目级收窄
if role_level < 2 {
let project_ids : Vec < i64 > = params
. get ( " scope_project_ids " )
. and_then ( | v | v . as_array ( ) )
. map ( | arr | arr . iter ( ) . filter_map ( | v | v . as_i64 ( ) ) . collect ( ) )
. unwrap_or_default ( ) ;
let ( scope_cond , scope_binds ) =
auth ::UserScope { project_ids } . cabinet_condition ( " cr.cabinet_id " ) ;
auth ::append_where ( & mut where_clause , & scope_cond ) ;
bind_values . extend ( scope_binds ) ;
}
2026-09-20 00:01:01 +08:00
( where_clause , bind_values )
}
2026-09-20 02:44:51 +08:00
/// 查询能耗明细行(按「日期 + 柜子」聚合 `charge_records`)
///
/// 原来直接读 `energy_stats`(每柜每天一行);改为聚合后**必须显式分组**,
/// 分组键 = `DATE(start_time)` + `cabinet_id` + 柜名/项目名(`ONLY_FULL_GROUP_BY` 下
/// 非聚合列必须出现在 GROUP BY 中),输出列与原结构体完全一致。
2026-09-20 00:01:01 +08:00
async fn fetch_energy_detail_rows (
pool : & sqlx ::MySqlPool ,
where_clause : & str ,
bind_values : & [ String ] ,
) -> Result < Vec < EnergyDetailRow > , sqlx ::Error > {
2026-09-20 02:44:51 +08:00
// 组织隔离/筛选条件加在 WHERE 里,可能整段为空 ⇒ 这里必须自己兜住
// `start_time IS NULL`(该列可空):`DATE(NULL)` 会是 NULL,
// 反序列化成 `chrono::NaiveDate` 会直接失败,让整个导出任务挂掉。
let where_clause = if where_clause . is_empty ( ) {
" WHERE cr.start_time IS NOT NULL " . to_string ( )
} else {
format! ( " {} AND cr.start_time IS NOT NULL " , where_clause )
} ;
2026-07-02 05:38:01 +08:00
let sql = format! (
2026-09-20 02:44:51 +08:00
" SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, \
2026-07-04 04:03:02 +08:00
p . name AS project_name , \
2026-09-20 02:44:51 +08:00
CAST ( COALESCE ( SUM ( cr . energy ) , 0 ) AS SIGNED ) AS total_energy , \
CAST ( COUNT ( * ) AS SIGNED ) AS charge_count \
FROM charge_records cr \
LEFT JOIN cabinets c ON cr . cabinet_id = c . id \
2026-07-02 05:38:01 +08:00
LEFT JOIN projects p ON c . project_id = p . id \
2026-09-20 02:44:51 +08:00
{ } \
GROUP BY DATE ( cr . start_time ) , cr . cabinet_id , c . name , p . name \
ORDER BY stat_date DESC , cr . cabinet_id ASC \
LIMIT { } " ,
where_clause , EXPORT_MAX_ROWS
2026-07-02 05:38:01 +08:00
) ;
let mut query = sqlx ::query_as ::< _ , EnergyDetailRow > ( & sql ) ;
2026-09-20 00:01:01 +08:00
for val in bind_values {
2026-07-02 05:38:01 +08:00
query = query . bind ( val ) ;
}
2026-09-20 00:01:01 +08:00
query . fetch_all ( pool ) . await
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 构建导出工作簿(表头 + 数据行)
fn build_energy_workbook (
rows : & [ EnergyDetailRow ] ,
) -> Result < rust_xlsxwriter ::Workbook , Box < dyn std ::error ::Error + Send + Sync > > {
2026-07-02 05:38:01 +08:00
let mut workbook = rust_xlsxwriter ::Workbook ::new ( ) ;
let sheet = workbook . add_worksheet ( ) ;
sheet . set_name ( " 能耗统计 " ) ? ;
let headers = [
2026-07-04 04:03:02 +08:00
" 日期 " , " 柜子ID " , " 柜子名称 " , " 所属项目 " , " 能耗(Wh) " , " 充电次数 " ,
2026-07-02 05:38:01 +08:00
] ;
for ( col , header ) in headers . iter ( ) . enumerate ( ) {
sheet . write_string ( 0 , col as u16 , * header ) ? ;
}
for ( row_idx , record ) in rows . iter ( ) . enumerate ( ) {
let row = ( row_idx + 1 ) as u32 ;
sheet . write_string ( row , 0 , record . stat_date . to_string ( ) ) ? ;
sheet . write_number ( row , 1 , record . cabinet_id as f64 ) ? ;
sheet . write_string ( row , 2 , record . cabinet_name . as_deref ( ) . unwrap_or ( " - " ) ) ? ;
sheet . write_string ( row , 3 , record . project_name . as_deref ( ) . unwrap_or ( " - " ) ) ? ;
2026-07-04 04:03:02 +08:00
sheet . write_number ( row , 4 , record . total_energy . unwrap_or ( 0 ) as f64 ) ? ;
2026-07-02 05:38:01 +08:00
sheet . write_number ( row , 5 , record . charge_count . unwrap_or ( 0 ) as f64 ) ? ;
}
2026-09-20 00:01:01 +08:00
Ok ( workbook )
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed (
pool : & sqlx ::MySqlPool ,
task_id : i64 ,
file_path : & str ,
) -> Result < ( ) , sqlx ::Error > {
2026-07-02 05:38:01 +08:00
sqlx ::query (
" UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL , completed_at = NOW ( ) WHERE id = ? "
)
2026-09-20 00:01:01 +08:00
. bind ( file_path )
2026-07-02 05:38:01 +08:00
. bind ( task_id )
2026-09-20 00:01:01 +08:00
. execute ( pool )
2026-07-02 05:38:01 +08:00
. await ? ;
2026-09-20 00:01:01 +08:00
Ok ( ( ) )
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel (
pool : sqlx ::MySqlPool ,
task_id : i64 ,
) -> Result < ( ) , Box < dyn std ::error ::Error + Send + Sync > > {
// 更新状态为处理中
sqlx ::query ( " UPDATE download_tasks SET status = 1 WHERE id = ? " )
. bind ( task_id )
. execute ( & pool )
. await ? ;
// 获取查询参数
let ( file_name , params ) = load_export_task ( & pool , task_id ) . await ? ;
// 组织过滤(非总管理员)+ 查询能耗明细
let ( where_clause , bind_values ) = build_export_where ( & params ) ;
let rows = fetch_energy_detail_rows ( & pool , & where_clause , & bind_values ) . await ? ;
// 更新进度 50%
sqlx ::query ( " UPDATE download_tasks SET progress = 50 WHERE id = ? " )
. bind ( task_id )
. execute ( & pool )
. await ? ;
// 生成 Excel
let mut workbook = build_energy_workbook ( & rows ) ? ;
// 保存文件
let file_dir = " ./exports " ;
tokio ::fs ::create_dir_all ( file_dir ) . await ? ;
let file_path = format! ( " {} / {} " , file_dir , file_name ) ;
workbook . save ( & file_path ) ? ;
// 更新任务状态为完成
mark_task_completed ( & pool , task_id , & file_path ) . await ? ;
2026-07-02 05:38:01 +08:00
tracing ::info! ( " 能耗统计导出完成 task_id={} file={} " , task_id , file_path ) ;
Ok ( ( ) )
}
2026-09-20 02:44:51 +08:00
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[ cfg(test) ]
mod tests {
use super ::* ;
/// 趋势 `range` 夹紧:负数会让 `DATE_SUB(CURDATE(), INTERVAL -5 DAY)` 变成"未来 5 天",
/// 0 会让窗口退化成"仅今天零点以后",超大值会全表扫描。
#[ test ]
fn test_clamp_range_bounds_illegal_values ( ) {
assert_eq! ( clamp_range ( 30 ) , 30 , " 合法值应原样保留 " ) ;
assert_eq! ( clamp_range ( 0 ) , TREND_RANGE_MIN , " 0 应被抬到下限 " ) ;
assert_eq! ( clamp_range ( - 5 ) , TREND_RANGE_MIN , " 负数应被抬到下限 " ) ;
assert_eq! ( clamp_range ( 999_999 ) , TREND_RANGE_MAX , " 超大值应被压到上限 " ) ;
}
/// 时间窗口片段必须只按 `start_time` 归属(不得出现 `end_time` / `stat_date`) ,
/// 且今日/本月都必须是半开区间(索引可用的写法)。
#[ test ]
fn test_time_window_uses_start_time_half_open_ranges ( ) {
assert! ( time_window ::TODAY . contains ( " cr.start_time >= CURDATE() " ) ) ;
assert! ( time_window ::TODAY . contains ( " cr.start_time < CURDATE() + INTERVAL 1 DAY " ) ) ;
assert! ( time_window ::THIS_MONTH . contains ( " cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') " ) ) ;
assert! ( time_window ::THIS_MONTH . contains ( " INTERVAL 1 MONTH " ) ) ;
// 导出结束日期必须"含当天" ⇒ 用 < 次日 00:00:00, 而不是 <= 当天(会丢掉当天全部记录)
assert_eq! ( time_window ::BEFORE_NEXT_DAY , " cr.start_time < DATE_ADD(?, INTERVAL 1 DAY) " ) ;
for window in [ time_window ::TODAY , time_window ::THIS_MONTH ] {
assert! ( ! window . contains ( " end_time " ) , " 口径必须是 start_time: {} " , window ) ;
assert! ( ! window . contains ( " stat_date " ) , " 不得再引用 energy_stats.stat_date: {} " , window ) ;
}
}
/// 导出 WHERE 构建:列名必须指向 charge_records( `cr.`) ,
/// 日期条件必须落在 `cr.start_time`,且参数一律走占位符(不做字符串拼接)。
#[ test ]
fn test_build_export_clause_targets_charge_records ( ) {
let ( clause , binds ) = build_export_clause (
Some ( 7 ) ,
Some ( 3 ) ,
Some ( " 2026-01-01 " ) ,
Some ( " 2026-01-31 " ) ,
) ;
assert! ( clause . starts_with ( " WHERE " ) , " 非空条件应带 WHERE: {} " , clause ) ;
assert! ( clause . contains ( " cr.cabinet_id = ? " ) , " 柜子过滤应指向 cr: {} " , clause ) ;
assert! ( clause . contains ( " c.project_id = ? " ) , " 项目过滤应指向 cabinets: {} " , clause ) ;
assert! ( clause . contains ( " cr.start_time >= ? " ) , " 起始日期应落在 cr.start_time: {} " , clause ) ;
assert! ( clause . contains ( time_window ::BEFORE_NEXT_DAY ) , " 结束日期应含当天:{} " , clause ) ;
assert! ( ! clause . contains ( " energy_stats " ) , " 不得再引用 energy_stats: {} " , clause ) ;
assert_eq! ( binds , vec! [ " 7 " , " 3 " , " 2026-01-01 " , " 2026-01-31 " ] ) ;
}
/// 无条件时应返回空子句(由 `fetch_energy_detail_rows` 自己补 `start_time IS NOT NULL` 兜底)
#[ test ]
fn test_build_export_clause_empty_when_no_filters ( ) {
let ( clause , binds ) = build_export_clause ( None , None , None , None ) ;
assert! ( clause . is_empty ( ) ) ;
assert! ( binds . is_empty ( ) ) ;
}
/// 组织隔离:非总管理员 + 有组织 ⇒ 必须追加 `cr.cabinet_id IN (...)` 子查询并带上组织 ID;
/// 非总管理员 + 无组织 ⇒ 必须追加 `1=0` 空集(**绝不退化成"全部"**)。
#[ test ]
fn test_build_export_where_keeps_org_isolation ( ) {
// 总管理员:不加任何组织条件
let ( clause , binds ) = build_export_where ( & json! ( { " role_level " : 2 } ) ) ;
assert! ( clause . is_empty ( ) , " 总管理员不应有额外条件:{} " , clause ) ;
assert! ( binds . is_empty ( ) ) ;
// 组织管理员:追加组织子查询
let ( clause , binds ) = build_export_where ( & json! ( { " role_level " : 1 , " org_id " : 42 } ) ) ;
assert! (
clause . contains ( " cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?) " ) ,
" 组织隔离条件丢失:{} " ,
clause
) ;
assert_eq! ( binds , vec! [ " 42 " ] ) ;
// 客户侧账号但无组织:空集
let ( clause , binds ) = build_export_where ( & json! ( { " role_level " : 0 } ) ) ;
assert_eq! ( clause , " WHERE 1=0 " ) ;
assert! ( binds . is_empty ( ) ) ;
// 已有条件时组织条件用 AND 拼接(不能覆盖原有筛选)
let ( clause , _ ) = build_export_where ( & json! ( { " cabinet_id " : 9 , " role_level " : 1 , " org_id " : 42 } ) ) ;
assert! ( clause . contains ( " cr.cabinet_id = ? AND cr.cabinet_id IN ( " ) , " 拼接方式错误:{} " , clause ) ;
}
/// 电费估算口径:`energy` 单位 Wh, 单价 0.80 元/kWh ⇒ 1000 Wh = 0.80 元。
#[ test ]
fn test_estimated_cost_unit_is_wh_times_80_cent_per_kwh ( ) {
let month_energy_wh : i64 = 1000 ;
let cost = ( month_energy_wh * COST_PER_WH_CENT / 1000 ) as f64 / 100.0 ;
assert! ( ( cost - 0.80 ) . abs ( ) < f64 ::EPSILON , " 1000Wh 应为 0.8 元,实际 {} " , cost ) ;
// 22 Wh( 联调实测值) ≈ 0.0176 元,不应被当成 22 kWh( 那会是 17.6 元)
let small = ( 22_ i64 * COST_PER_WH_CENT / 1000 ) as f64 / 100.0 ;
assert! ( small < 0.02 , " 22Wh 不应被当成 22kWh: {} " , small ) ;
}
2026-09-20 04:31:42 +08:00
// -----------------------------------------------------------------------
// 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线")
// -----------------------------------------------------------------------
/// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给)
fn user ( role_level : i32 , organization_id : Option < i64 > , project_ids : Vec < i64 > ) -> CurrentUser {
CurrentUser {
user_id : 1 ,
phone : " 13000000000 " . to_string ( ) ,
permissions : Default ::default ( ) ,
role_level ,
organization_id ,
scope : crate ::middleware ::auth ::UserScope { project_ids } ,
}
}
/// ① **在线统计不依赖 `cabinets.status`**。
///
/// `cabinets.status` 是启用/禁用**配置态**:全仓唯一写入方
/// ( `routes/cabinets.rs::set_cabinet_status`)只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0( 离线) **。所以只要 SQL 里出现 `status` 条件,
/// 就必然在"一台设备都没连"时返回"全部在线"。
#[ test ]
fn test_visible_cabinet_sql_never_filters_by_status ( ) {
let cases = [
user ( 2 , None , vec! [ ] ) , // 总管理员
user ( 1 , Some ( 42 ) , vec! [ ] ) , // 组织管理员(未配范围)
user ( 1 , Some ( 42 ) , vec! [ 7 , 8 ] ) , // 组织管理员 + 项目级范围
user ( 0 , None , vec! [ ] ) , // 客户侧账号无组织 ⇒ 空集
user ( 0 , Some ( 42 ) , vec! [ ] ) , // 操作员
] ;
for u in & cases {
let ( count_sql , _ ) = visible_cabinets_count_sql ( u ) ;
let ( imei_sql , _ ) = visible_cabinet_imeis_sql ( u ) ;
for sql in [ & count_sql , & imei_sql ] {
assert! (
! sql . to_lowercase ( ) . contains ( " status " ) ,
" 柜数/在线统计不得引用 cabinets.status: {} " ,
sql
) ;
}
}
}
/// ② 组织隔离与数据范围**必须保留**(收窄只能取交集,绝不放大):
/// 总管理员豁免;非总管理员带组织条件 + 项目级范围;**无组织 ⇒ 空集**(不是"全部")。
#[ test ]
fn test_visible_cabinet_sql_keeps_org_isolation_and_scope ( ) {
// 总管理员豁免:即使身上带着组织与范围,也不得出现在 SQL 里
let ( sql , binds ) = visible_cabinet_imeis_sql ( & user ( 2 , Some ( 99 ) , vec! [ 7 ] ) ) ;
assert! ( ! sql . contains ( " organization_id " ) , " 总管理员应豁免组织条件:{} " , sql ) ;
assert! ( ! sql . contains ( " IN ( " ) , " 总管理员应豁免数据范围:{} " , sql ) ;
assert! ( binds . is_empty ( ) , " 总管理员不应有任何绑定值 " ) ;
// 组织管理员:组织条件 + 项目级范围(绑定顺序必须与占位符顺序一致)
let ( sql , binds ) = visible_cabinet_imeis_sql ( & user ( 1 , Some ( 42 ) , vec! [ 7 , 8 ] ) ) ;
assert! ( sql . contains ( " p.organization_id = ? " ) , " 组织隔离丢失:{} " , sql ) ;
assert! ( sql . contains ( " c.project_id IN (?,?) " ) , " 数据范围丢失:{} " , sql ) ;
assert_eq! ( binds , vec! [ " 42 " , " 7 " , " 8 " ] , " 绑定顺序必须与占位符一致 " ) ;
// 客户侧账号无组织 ⇒ 空集(**绝不退化成"全部"**),两条 SQL 都必须如此
let ( imei_sql , binds ) = visible_cabinet_imeis_sql ( & user ( 0 , None , vec! [ ] ) ) ;
assert! ( imei_sql . contains ( " 1=0 " ) , " 无组织时在线取数必须是空集:{} " , imei_sql ) ;
assert! ( binds . is_empty ( ) ) ;
let ( count_sql , _ ) = visible_cabinets_count_sql ( & user ( 0 , None , vec! [ ] ) ) ;
assert! ( count_sql . contains ( " 1=0 " ) , " 无组织时柜数必须是空集:{} " , count_sql ) ;
// 在线取数只取 IMEI( 在线判定交给 Redis, DB 不参与)
let ( sql , _ ) = visible_cabinet_imeis_sql ( & user ( 1 , Some ( 42 ) , vec! [ ] ) ) ;
assert! ( sql . starts_with ( " SELECT c.imei FROM cabinets c " ) , " 取数应只取 IMEI: {} " , sql ) ;
assert! ( sql . contains ( " c.imei IS NOT NULL " ) , " 必须排除 NULL 的 IMEI: {} " , sql ) ;
}
/// ③ **与设备列表 `is_online` 口径同源**: `energy_stats` 没有第二套在线判据 ——
/// 在线计数一律经 `cabinet_tree::count_online_flags`,其唯一依据是
/// `cabinet_tree::is_device_online` / `device_online_flag`(设备列表用的是同一对函数)。
/// 逐值断言同一批原始值在两处结论完全一致, 并覆盖「0 台连接 ⇒ 0」这条语义。
#[ test ]
fn test_online_count_same_source_as_cabinet_tree_device_list ( ) {
// 同一批原始值,覆盖三态边界:在线 / 明确离线 / 无数据 / 空串 / 非法值
let raws : Vec < Option < String > > = vec! [
Some ( " 1 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
None ,
Some ( String ::new ( ) ) ,
Some ( " 2 " . into ( ) ) ,
] ;
// 期望值直接由设备列表的判据(`is_online` == `device_online_flag(..) == Some(true)`)给出
let expected = raws
. iter ( )
. filter ( | v | cabinet_tree ::device_online_flag ( v . as_deref ( ) ) = = Some ( true ) )
. count ( ) as i64 ;
assert_eq! (
cabinet_tree ::count_online_flags ( & raws ) ,
expected ,
" 在线计数与设备列表 is_online 判据必须同源 "
) ;
assert_eq! ( expected , 1 , " 5 个样本里只有 1 个明确在线 " ) ;
// **0 台设备连接 ⇒ online_count = 0**(而不是等于柜子总数)——生产实测的回归形态
let nothing_connected : Vec < Option < String > > = vec! [ None ; 68 ] ;
assert_eq! (
cabinet_tree ::count_online_flags ( & nothing_connected ) ,
0 ,
" 无设备连接时在线数必须是 0, 绝不能退化成柜子总数 "
) ;
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
assert_eq! ( cabinet_tree ::count_online_flags ( & [ ] ) , 0 ) ;
}
2026-09-20 02:44:51 +08:00
}