554 lines
18 KiB
Rust
Raw Normal View History

//! 能耗管理 API — 总览、排行、趋势、导出
//!
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
//! 电费估算按 ¥0.80/kWh 计算(可配置)。
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};
/// 电费单价(元/kWh)
const COST_PER_KWH: f64 = 0.80;
/// 趋势查询参数
#[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>,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 项目统计行
#[derive(Debug, sqlx::FromRow)]
struct ProjectStatRow {
id: i64,
name: String,
cabinet_count: i64,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 趋势数据行
#[derive(Debug, sqlx::FromRow)]
struct TrendRow {
period: String, // 日期或周/月标识
energy: Option<f64>,
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>,
total_energy: Option<f64>,
charge_count: Option<i64>,
}
fn default_granularity() -> String { "day".to_string() }
fn default_range() -> i64 { 30 }
/// 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;
// 组织过滤子查询
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.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 today_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
org_filter
);
let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
for v in &org_binds { today_q = today_q.bind(v); }
let today_energy: f64 = today_q.fetch_one(pool).await?;
// 本月总能耗
let month_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
org_filter
);
let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: f64 = month_q.fetch_one(pool).await?;
// 柜子总数(含组织过滤)
let cabinet_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
// 在线柜子数
let online_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
let today = today_energy;
let month = month_energy;
Ok(Json(json!({
"code": 0,
"data": {
"today_energy": (today * 100.0).round() / 100.0,
"month_energy": (month * 100.0).round() / 100.0,
"estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
"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;
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![])
};
let sql = format!(
"SELECT c.id, c.abstract_id, c.name, \
COALESCE(es_today.total_energy, 0) AS today_energy, \
COALESCE(es_month.total_energy, 0) AS month_energy \
FROM cabinets c \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join
);
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,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).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![])
};
let sql = format!(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
COALESCE(SUM(es_month.total_energy), 0) AS month_energy \
FROM projects p \
LEFT JOIN cabinets c ON p.id = c.project_id \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
org_filter
);
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,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).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;
// 组织过滤:es.cabinet_id → cabinets.project_id → projects.organization_id
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.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 rows = match params.granularity.as_str() {
"week" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
"month" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
_ => {
// day
let sql = format!(
"SELECT stat_date AS period, \
total_energy AS energy, charge_count AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
ORDER BY stat_date ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
};
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"period": r.period,
"energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"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);
// 序列化查询参数
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,
}))
.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)
}
/// 构建导出条件
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 {
conditions.push("es.cabinet_id = ?");
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 {
conditions.push("es.stat_date >= ?");
bind_values.push(d.to_string());
}
if let Some(d) = end_date {
conditions.push("es.stat_date <= ?");
bind_values.push(d.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 后台异步生成能耗 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 row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
let (where_clause, bind_values) = build_export_clause(
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()),
);
// 查询能耗明细
let sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
CAST(es.total_energy AS DOUBLE) AS total_energy, \
es.charge_count \
FROM energy_stats es \
LEFT JOIN cabinets c ON es.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
where_clause
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows = query.fetch_all(&pool).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(kWh)", "充电次数",
];
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("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0.0))?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}