ems-core v1.0.0: Standard EMS platform core
Shared backend + frontend for multi-customer EMS deployments. - 12 enterprise modules: quota, cost, charging, maintenance, analysis, etc. - 120+ API endpoints, 37 database tables - Customer config mechanism (CUSTOMER env var + YAML config) - Collectors: Modbus TCP, MQTT, HTTP API, Sungrow iSolarCloud - Frontend: React 19 + Ant Design + ECharts + Three.js - Infrastructure: Redis cache, rate limiting, aggregation engine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
590
backend/app/api/v1/ai_ops.py
Normal file
590
backend/app/api/v1/ai_ops.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""AI运维智能体 API - 设备健康、异常检测、诊断、预测维护、洞察"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.device import Device
|
||||
from app.models.ai_ops import (
|
||||
DeviceHealthScore, AnomalyDetection, DiagnosticReport,
|
||||
MaintenancePrediction, OpsInsight,
|
||||
)
|
||||
from app.services.ai_ops import (
|
||||
calculate_device_health, scan_anomalies, run_diagnostics,
|
||||
generate_maintenance_predictions, generate_insights, get_dashboard_data,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/ai-ops", tags=["AI运维智能体"])
|
||||
|
||||
|
||||
# ── Device Health ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health")
|
||||
async def get_all_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取所有设备最新健康评分"""
|
||||
subq = (
|
||||
select(
|
||||
DeviceHealthScore.device_id,
|
||||
func.max(DeviceHealthScore.timestamp).label("max_ts"),
|
||||
).group_by(DeviceHealthScore.device_id).subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(DeviceHealthScore).join(
|
||||
subq, and_(
|
||||
DeviceHealthScore.device_id == subq.c.device_id,
|
||||
DeviceHealthScore.timestamp == subq.c.max_ts,
|
||||
)
|
||||
)
|
||||
)
|
||||
scores = result.scalars().all()
|
||||
|
||||
# Get device info
|
||||
device_ids = [s.device_id for s in scores]
|
||||
dev_map = {}
|
||||
if device_ids:
|
||||
dev_result = await db.execute(
|
||||
select(Device.id, Device.name, Device.device_type, Device.code)
|
||||
.where(Device.id.in_(device_ids))
|
||||
)
|
||||
dev_map = {r.id: {"name": r.name, "type": r.device_type, "code": r.code} for r in dev_result.all()}
|
||||
|
||||
return [{
|
||||
"device_id": s.device_id,
|
||||
"device_name": dev_map.get(s.device_id, {}).get("name", f"#{s.device_id}"),
|
||||
"device_type": dev_map.get(s.device_id, {}).get("type", "unknown"),
|
||||
"device_code": dev_map.get(s.device_id, {}).get("code", ""),
|
||||
"health_score": s.health_score,
|
||||
"status": s.status,
|
||||
"trend": s.trend,
|
||||
"factors": s.factors,
|
||||
"timestamp": str(s.timestamp),
|
||||
} for s in scores]
|
||||
|
||||
|
||||
@router.get("/health/{device_id}")
|
||||
async def get_device_health(
|
||||
device_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取单设备健康详情"""
|
||||
result = await db.execute(
|
||||
select(DeviceHealthScore).where(
|
||||
DeviceHealthScore.device_id == device_id
|
||||
).order_by(DeviceHealthScore.timestamp.desc()).limit(1)
|
||||
)
|
||||
score = result.scalar_one_or_none()
|
||||
if not score:
|
||||
raise HTTPException(status_code=404, detail="暂无该设备健康数据")
|
||||
|
||||
dev_result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = dev_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"device_id": score.device_id,
|
||||
"device_name": device.name if device else f"#{device_id}",
|
||||
"device_type": device.device_type if device else "unknown",
|
||||
"health_score": score.health_score,
|
||||
"status": score.status,
|
||||
"trend": score.trend,
|
||||
"factors": score.factors,
|
||||
"timestamp": str(score.timestamp),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/{device_id}/history")
|
||||
async def get_health_history(
|
||||
device_id: int,
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取设备健康评分历史"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
select(DeviceHealthScore).where(and_(
|
||||
DeviceHealthScore.device_id == device_id,
|
||||
DeviceHealthScore.timestamp >= cutoff,
|
||||
)).order_by(DeviceHealthScore.timestamp.asc())
|
||||
)
|
||||
scores = result.scalars().all()
|
||||
return [{
|
||||
"timestamp": str(s.timestamp),
|
||||
"health_score": s.health_score,
|
||||
"status": s.status,
|
||||
"trend": s.trend,
|
||||
"factors": s.factors,
|
||||
} for s in scores]
|
||||
|
||||
|
||||
@router.post("/health/calculate")
|
||||
async def trigger_health_calculation(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发全部设备健康评分计算"""
|
||||
result = await db.execute(select(Device).where(Device.is_active == True))
|
||||
devices = result.scalars().all()
|
||||
scores = []
|
||||
for device in devices:
|
||||
try:
|
||||
score = await calculate_device_health(db, device)
|
||||
scores.append({
|
||||
"device_id": score.device_id,
|
||||
"health_score": score.health_score,
|
||||
"status": score.status,
|
||||
})
|
||||
except Exception as e:
|
||||
scores.append({"device_id": device.id, "error": str(e)})
|
||||
return {"calculated": len(scores), "results": scores}
|
||||
|
||||
|
||||
# ── Anomaly Detection ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/anomalies")
|
||||
async def list_anomalies(
|
||||
device_id: int | None = None,
|
||||
severity: str | None = None,
|
||||
status: str | None = None,
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出异常检测记录"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
query = select(AnomalyDetection).where(AnomalyDetection.detected_at >= cutoff)
|
||||
if device_id:
|
||||
query = query.where(AnomalyDetection.device_id == device_id)
|
||||
if severity:
|
||||
query = query.where(AnomalyDetection.severity == severity)
|
||||
if status:
|
||||
query = query.where(AnomalyDetection.status == status)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(AnomalyDetection.detected_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
anomalies = result.scalars().all()
|
||||
|
||||
# Get device names
|
||||
dev_ids = list(set(a.device_id for a in anomalies))
|
||||
dev_map = {}
|
||||
if dev_ids:
|
||||
dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids)))
|
||||
dev_map = {r.id: r.name for r in dev_result.all()}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": a.id,
|
||||
"device_id": a.device_id,
|
||||
"device_name": dev_map.get(a.device_id, f"#{a.device_id}"),
|
||||
"detected_at": str(a.detected_at),
|
||||
"anomaly_type": a.anomaly_type,
|
||||
"severity": a.severity,
|
||||
"description": a.description,
|
||||
"metric_name": a.metric_name,
|
||||
"expected_value": a.expected_value,
|
||||
"actual_value": a.actual_value,
|
||||
"deviation_percent": a.deviation_percent,
|
||||
"status": a.status,
|
||||
"resolution_notes": a.resolution_notes,
|
||||
} for a in anomalies],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/anomalies/{device_id}")
|
||||
async def get_device_anomalies(
|
||||
device_id: int,
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取设备异常记录"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
select(AnomalyDetection).where(and_(
|
||||
AnomalyDetection.device_id == device_id,
|
||||
AnomalyDetection.detected_at >= cutoff,
|
||||
)).order_by(AnomalyDetection.detected_at.desc())
|
||||
)
|
||||
anomalies = result.scalars().all()
|
||||
return [{
|
||||
"id": a.id,
|
||||
"detected_at": str(a.detected_at),
|
||||
"anomaly_type": a.anomaly_type,
|
||||
"severity": a.severity,
|
||||
"description": a.description,
|
||||
"metric_name": a.metric_name,
|
||||
"expected_value": a.expected_value,
|
||||
"actual_value": a.actual_value,
|
||||
"deviation_percent": a.deviation_percent,
|
||||
"status": a.status,
|
||||
} for a in anomalies]
|
||||
|
||||
|
||||
class AnomalyStatusUpdate(BaseModel):
|
||||
status: str # investigating, resolved, false_positive
|
||||
resolution_notes: str | None = None
|
||||
|
||||
|
||||
@router.put("/anomalies/{anomaly_id}/status")
|
||||
async def update_anomaly_status(
|
||||
anomaly_id: int,
|
||||
data: AnomalyStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新异常状态"""
|
||||
result = await db.execute(select(AnomalyDetection).where(AnomalyDetection.id == anomaly_id))
|
||||
anomaly = result.scalar_one_or_none()
|
||||
if not anomaly:
|
||||
raise HTTPException(status_code=404, detail="异常记录不存在")
|
||||
anomaly.status = data.status
|
||||
if data.resolution_notes:
|
||||
anomaly.resolution_notes = data.resolution_notes
|
||||
return {"message": "已更新", "id": anomaly.id, "status": anomaly.status}
|
||||
|
||||
|
||||
@router.post("/anomalies/scan")
|
||||
async def trigger_anomaly_scan(
|
||||
device_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发异常扫描"""
|
||||
anomalies = await scan_anomalies(db, device_id)
|
||||
return {
|
||||
"scanned_at": str(datetime.now(timezone.utc)),
|
||||
"anomalies_found": len(anomalies),
|
||||
"anomalies": [{
|
||||
"device_id": a.device_id,
|
||||
"anomaly_type": a.anomaly_type,
|
||||
"severity": a.severity,
|
||||
"description": a.description,
|
||||
} for a in anomalies],
|
||||
}
|
||||
|
||||
|
||||
# ── Diagnostics ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/diagnostics")
|
||||
async def list_diagnostics(
|
||||
device_id: int | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出诊断报告"""
|
||||
query = select(DiagnosticReport)
|
||||
if device_id:
|
||||
query = query.where(DiagnosticReport.device_id == device_id)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(DiagnosticReport.generated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
reports = result.scalars().all()
|
||||
|
||||
dev_ids = list(set(r.device_id for r in reports))
|
||||
dev_map = {}
|
||||
if dev_ids:
|
||||
dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids)))
|
||||
dev_map = {r.id: r.name for r in dev_result.all()}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": r.id,
|
||||
"device_id": r.device_id,
|
||||
"device_name": dev_map.get(r.device_id, f"#{r.device_id}"),
|
||||
"generated_at": str(r.generated_at),
|
||||
"report_type": r.report_type,
|
||||
"findings": r.findings,
|
||||
"recommendations": r.recommendations,
|
||||
"estimated_impact": r.estimated_impact,
|
||||
"status": r.status,
|
||||
} for r in reports],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/diagnostics/{device_id}/run")
|
||||
async def trigger_diagnostics(
|
||||
device_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""对指定设备运行诊断"""
|
||||
try:
|
||||
report = await run_diagnostics(db, device_id)
|
||||
return {
|
||||
"id": report.id,
|
||||
"device_id": report.device_id,
|
||||
"report_type": report.report_type,
|
||||
"findings": report.findings,
|
||||
"recommendations": report.recommendations,
|
||||
"estimated_impact": report.estimated_impact,
|
||||
"status": report.status,
|
||||
"generated_at": str(report.generated_at),
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/diagnostics/{report_id}")
|
||||
async def get_diagnostic_detail(
|
||||
report_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取诊断报告详情"""
|
||||
result = await db.execute(select(DiagnosticReport).where(DiagnosticReport.id == report_id))
|
||||
report = result.scalar_one_or_none()
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="诊断报告不存在")
|
||||
|
||||
dev_result = await db.execute(select(Device.name).where(Device.id == report.device_id))
|
||||
device_name = dev_result.scalar() or f"#{report.device_id}"
|
||||
|
||||
return {
|
||||
"id": report.id,
|
||||
"device_id": report.device_id,
|
||||
"device_name": device_name,
|
||||
"generated_at": str(report.generated_at),
|
||||
"report_type": report.report_type,
|
||||
"findings": report.findings,
|
||||
"recommendations": report.recommendations,
|
||||
"estimated_impact": report.estimated_impact,
|
||||
"status": report.status,
|
||||
}
|
||||
|
||||
|
||||
# ── Predictive Maintenance ──────────────────────────────────────────
|
||||
|
||||
@router.get("/maintenance/predictions")
|
||||
async def list_predictions(
|
||||
status: str | None = None,
|
||||
urgency: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出维护预测"""
|
||||
query = select(MaintenancePrediction)
|
||||
if status:
|
||||
query = query.where(MaintenancePrediction.status == status)
|
||||
if urgency:
|
||||
query = query.where(MaintenancePrediction.urgency == urgency)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(MaintenancePrediction.predicted_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
predictions = result.scalars().all()
|
||||
|
||||
dev_ids = list(set(p.device_id for p in predictions))
|
||||
dev_map = {}
|
||||
if dev_ids:
|
||||
dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids)))
|
||||
dev_map = {r.id: r.name for r in dev_result.all()}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": p.id,
|
||||
"device_id": p.device_id,
|
||||
"device_name": dev_map.get(p.device_id, f"#{p.device_id}"),
|
||||
"predicted_at": str(p.predicted_at),
|
||||
"component": p.component,
|
||||
"failure_mode": p.failure_mode,
|
||||
"probability": p.probability,
|
||||
"predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None,
|
||||
"recommended_action": p.recommended_action,
|
||||
"urgency": p.urgency,
|
||||
"estimated_downtime_hours": p.estimated_downtime_hours,
|
||||
"estimated_repair_cost": p.estimated_repair_cost,
|
||||
"status": p.status,
|
||||
} for p in predictions],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/schedule")
|
||||
async def get_maintenance_schedule(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取推荐维护计划"""
|
||||
result = await db.execute(
|
||||
select(MaintenancePrediction).where(
|
||||
MaintenancePrediction.status.in_(["predicted", "scheduled"])
|
||||
).order_by(MaintenancePrediction.predicted_failure_date.asc())
|
||||
)
|
||||
predictions = result.scalars().all()
|
||||
|
||||
dev_ids = list(set(p.device_id for p in predictions))
|
||||
dev_map = {}
|
||||
if dev_ids:
|
||||
dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids)))
|
||||
dev_map = {r.id: r.name for r in dev_result.all()}
|
||||
|
||||
return [{
|
||||
"id": p.id,
|
||||
"device_id": p.device_id,
|
||||
"device_name": dev_map.get(p.device_id, f"#{p.device_id}"),
|
||||
"component": p.component,
|
||||
"failure_mode": p.failure_mode,
|
||||
"probability": p.probability,
|
||||
"predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None,
|
||||
"recommended_action": p.recommended_action,
|
||||
"urgency": p.urgency,
|
||||
"estimated_downtime_hours": p.estimated_downtime_hours,
|
||||
"estimated_repair_cost": p.estimated_repair_cost,
|
||||
"status": p.status,
|
||||
} for p in predictions]
|
||||
|
||||
|
||||
class PredictionStatusUpdate(BaseModel):
|
||||
status: str # scheduled, completed, false_alarm
|
||||
|
||||
|
||||
@router.put("/maintenance/predictions/{prediction_id}")
|
||||
async def update_prediction(
|
||||
prediction_id: int,
|
||||
data: PredictionStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新预测状态"""
|
||||
result = await db.execute(select(MaintenancePrediction).where(MaintenancePrediction.id == prediction_id))
|
||||
pred = result.scalar_one_or_none()
|
||||
if not pred:
|
||||
raise HTTPException(status_code=404, detail="预测记录不存在")
|
||||
pred.status = data.status
|
||||
return {"message": "已更新", "id": pred.id, "status": pred.status}
|
||||
|
||||
|
||||
@router.post("/maintenance/predict")
|
||||
async def trigger_predictions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发维护预测生成"""
|
||||
predictions = await generate_maintenance_predictions(db)
|
||||
return {
|
||||
"generated": len(predictions),
|
||||
"predictions": [{
|
||||
"device_id": p.device_id,
|
||||
"component": p.component,
|
||||
"urgency": p.urgency,
|
||||
"probability": p.probability,
|
||||
} for p in predictions],
|
||||
}
|
||||
|
||||
|
||||
# ── Insights ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights")
|
||||
async def list_insights(
|
||||
insight_type: str | None = None,
|
||||
impact_level: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出运营洞察"""
|
||||
query = select(OpsInsight)
|
||||
if insight_type:
|
||||
query = query.where(OpsInsight.insight_type == insight_type)
|
||||
if impact_level:
|
||||
query = query.where(OpsInsight.impact_level == impact_level)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(OpsInsight.generated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
insights = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": i.id,
|
||||
"insight_type": i.insight_type,
|
||||
"title": i.title,
|
||||
"description": i.description,
|
||||
"data": i.data,
|
||||
"impact_level": i.impact_level,
|
||||
"actionable": i.actionable,
|
||||
"recommended_action": i.recommended_action,
|
||||
"generated_at": str(i.generated_at),
|
||||
"valid_until": str(i.valid_until) if i.valid_until else None,
|
||||
} for i in insights],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/insights/latest")
|
||||
async def get_latest_insights(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取最新洞察"""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(OpsInsight).where(
|
||||
OpsInsight.valid_until >= now
|
||||
).order_by(OpsInsight.generated_at.desc()).limit(limit)
|
||||
)
|
||||
insights = result.scalars().all()
|
||||
return [{
|
||||
"id": i.id,
|
||||
"insight_type": i.insight_type,
|
||||
"title": i.title,
|
||||
"description": i.description,
|
||||
"impact_level": i.impact_level,
|
||||
"actionable": i.actionable,
|
||||
"recommended_action": i.recommended_action,
|
||||
"generated_at": str(i.generated_at),
|
||||
} for i in insights]
|
||||
|
||||
|
||||
@router.post("/insights/generate")
|
||||
async def trigger_insights(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发洞察生成"""
|
||||
insights = await generate_insights(db)
|
||||
return {
|
||||
"generated": len(insights),
|
||||
"insights": [{
|
||||
"title": i.title,
|
||||
"insight_type": i.insight_type,
|
||||
"impact_level": i.impact_level,
|
||||
} for i in insights],
|
||||
}
|
||||
|
||||
|
||||
# ── Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def ai_ops_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""AI运维总览仪表盘"""
|
||||
return await get_dashboard_data(db)
|
||||
321
backend/app/api/v1/alarms.py
Normal file
321
backend/app/api/v1/alarms.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, String
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/alarms", tags=["告警管理"])
|
||||
|
||||
|
||||
class AlarmRuleCreate(BaseModel):
|
||||
name: str
|
||||
device_id: int | None = None
|
||||
device_type: str | None = None
|
||||
data_type: str
|
||||
condition: str
|
||||
threshold: float | None = None
|
||||
threshold_high: float | None = None
|
||||
threshold_low: float | None = None
|
||||
duration: int = 0
|
||||
severity: str = "warning"
|
||||
notify_channels: list[str] | None = None
|
||||
notify_targets: list[str] | None = None
|
||||
silence_start: str | None = None
|
||||
silence_end: str | None = None
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_rules(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmRule).order_by(AlarmRule.id.desc()))
|
||||
return [_rule_to_dict(r) for r in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
async def create_rule(data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
rule = AlarmRule(**data.model_dump(), created_by=user.id)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
return _rule_to_dict(rule)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_rule(rule_id: int, data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(rule, k, v)
|
||||
return _rule_to_dict(rule)
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
rule.is_active = False
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
device_id: int | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(AlarmEvent)
|
||||
if status:
|
||||
query = query.where(AlarmEvent.status == status)
|
||||
if severity:
|
||||
query = query.where(AlarmEvent.severity == severity)
|
||||
if device_id:
|
||||
query = query.where(AlarmEvent.device_id == device_id)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": e.id, "rule_id": e.rule_id, "device_id": e.device_id, "severity": e.severity,
|
||||
"title": e.title, "description": e.description, "value": e.value, "threshold": e.threshold,
|
||||
"status": e.status, "triggered_at": str(e.triggered_at),
|
||||
"acknowledged_at": str(e.acknowledged_at) if e.acknowledged_at else None,
|
||||
"resolved_at": str(e.resolved_at) if e.resolved_at else None,
|
||||
} for e in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/acknowledge")
|
||||
async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
event.status = "acknowledged"
|
||||
event.acknowledged_by = user.id
|
||||
event.acknowledged_at = datetime.now(timezone.utc)
|
||||
await log_audit(db, user.id, "acknowledge", "alarm", detail=f"确认告警 #{event_id}")
|
||||
return {"message": "已确认"}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/resolve")
|
||||
async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
event.status = "resolved"
|
||||
event.resolved_at = datetime.now(timezone.utc)
|
||||
event.resolve_note = note
|
||||
await log_audit(db, user.id, "resolve", "alarm", detail=f"解决告警 #{event_id}")
|
||||
return {"message": "已解决"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def alarm_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(AlarmEvent.severity, AlarmEvent.status, func.count(AlarmEvent.id))
|
||||
.group_by(AlarmEvent.severity, AlarmEvent.status)
|
||||
)
|
||||
stats = {}
|
||||
for severity, status, count in result.all():
|
||||
if severity not in stats:
|
||||
stats[severity] = {}
|
||||
stats[severity][status] = count
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
async def get_alarm_analytics(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""告警分析 - Alarm trends and patterns"""
|
||||
from datetime import timedelta
|
||||
if not end_date:
|
||||
end_dt = datetime.now(timezone.utc)
|
||||
else:
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
if not start_date:
|
||||
start_dt = end_dt - timedelta(days=30)
|
||||
else:
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
|
||||
# Daily alarm count by severity
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
if settings.is_sqlite:
|
||||
date_col = func.strftime('%Y-%m-%d', AlarmEvent.triggered_at).label('date')
|
||||
else:
|
||||
date_col = func.date_trunc('day', AlarmEvent.triggered_at).cast(String).label('date')
|
||||
|
||||
query = select(
|
||||
date_col,
|
||||
AlarmEvent.severity,
|
||||
func.count(AlarmEvent.id).label('count'),
|
||||
).where(
|
||||
and_(AlarmEvent.triggered_at >= start_dt, AlarmEvent.triggered_at <= end_dt)
|
||||
).group_by('date', AlarmEvent.severity).order_by('date')
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
daily_map: dict[str, dict] = {}
|
||||
totals = {"critical": 0, "major": 0, "warning": 0}
|
||||
for date_val, severity, count in rows:
|
||||
d = str(date_val)[:10]
|
||||
if d not in daily_map:
|
||||
daily_map[d] = {"date": d, "critical": 0, "major": 0, "warning": 0}
|
||||
daily_map[d][severity] = count
|
||||
if severity in totals:
|
||||
totals[severity] += count
|
||||
|
||||
return {
|
||||
"daily_trend": list(daily_map.values()),
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/top-devices")
|
||||
async def get_top_alarm_devices(
|
||||
limit: int = 10,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""告警设备排名 - Devices with most alarms"""
|
||||
from app.models.device import Device
|
||||
|
||||
query = select(
|
||||
AlarmEvent.device_id,
|
||||
Device.name.label('device_name'),
|
||||
func.count(AlarmEvent.id).label('alarm_count'),
|
||||
func.max(AlarmEvent.triggered_at).label('last_alarm_time'),
|
||||
).join(Device, AlarmEvent.device_id == Device.id)
|
||||
|
||||
if start_date:
|
||||
query = query.where(AlarmEvent.triggered_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(AlarmEvent.triggered_at <= end_date)
|
||||
|
||||
query = query.group_by(AlarmEvent.device_id, Device.name).order_by(
|
||||
func.count(AlarmEvent.id).desc()
|
||||
).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return [{
|
||||
"device_id": r.device_id,
|
||||
"device_name": r.device_name,
|
||||
"alarm_count": r.alarm_count,
|
||||
"last_alarm_time": str(r.last_alarm_time) if r.last_alarm_time else None,
|
||||
} for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/mttr")
|
||||
async def get_alarm_mttr(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""平均修复时间 - Mean Time To Resolve by severity"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
base_query = select(AlarmEvent).where(
|
||||
and_(AlarmEvent.status == "resolved", AlarmEvent.resolved_at.isnot(None))
|
||||
)
|
||||
if start_date:
|
||||
base_query = base_query.where(AlarmEvent.triggered_at >= start_date)
|
||||
if end_date:
|
||||
base_query = base_query.where(AlarmEvent.triggered_at <= end_date)
|
||||
|
||||
result = await db.execute(base_query)
|
||||
events = result.scalars().all()
|
||||
|
||||
mttr_data: dict[str, dict] = {}
|
||||
for e in events:
|
||||
if not e.resolved_at or not e.triggered_at:
|
||||
continue
|
||||
hours = (e.resolved_at - e.triggered_at).total_seconds() / 3600
|
||||
sev = e.severity
|
||||
if sev not in mttr_data:
|
||||
mttr_data[sev] = {"total_hours": 0, "count": 0}
|
||||
mttr_data[sev]["total_hours"] += hours
|
||||
mttr_data[sev]["count"] += 1
|
||||
|
||||
return {
|
||||
sev: {
|
||||
"avg_hours": round(d["total_hours"] / d["count"], 2) if d["count"] > 0 else 0,
|
||||
"count": d["count"],
|
||||
}
|
||||
for sev, d in mttr_data.items()
|
||||
}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}/toggle")
|
||||
async def toggle_rule(
|
||||
rule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""快速启用/禁用规则"""
|
||||
result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
rule.is_active = not rule.is_active
|
||||
return {"id": rule.id, "is_active": rule.is_active}
|
||||
|
||||
|
||||
@router.get("/rules/{rule_id}/history")
|
||||
async def get_rule_history(
|
||||
rule_id: int,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""规则触发历史"""
|
||||
query = select(AlarmEvent).where(AlarmEvent.rule_id == rule_id)
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": e.id, "device_id": e.device_id, "severity": e.severity,
|
||||
"title": e.title, "value": e.value, "threshold": e.threshold,
|
||||
"status": e.status, "triggered_at": str(e.triggered_at),
|
||||
"resolved_at": str(e.resolved_at) if e.resolved_at else None,
|
||||
} for e in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
def _rule_to_dict(r: AlarmRule) -> dict:
|
||||
return {
|
||||
"id": r.id, "name": r.name, "device_id": r.device_id, "device_type": r.device_type,
|
||||
"data_type": r.data_type, "condition": r.condition, "threshold": r.threshold,
|
||||
"threshold_high": r.threshold_high, "threshold_low": r.threshold_low,
|
||||
"duration": r.duration, "severity": r.severity, "is_active": r.is_active,
|
||||
"notify_channels": r.notify_channels, "silence_start": r.silence_start, "silence_end": r.silence_end,
|
||||
}
|
||||
76
backend/app/api/v1/audit.py
Normal file
76
backend/app/api/v1/audit.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def list_audit_logs(
|
||||
user_id: int | None = None,
|
||||
action: str | None = None,
|
||||
resource: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""Return paginated audit logs with optional filters."""
|
||||
query = select(
|
||||
AuditLog.id,
|
||||
AuditLog.user_id,
|
||||
User.username,
|
||||
AuditLog.action,
|
||||
AuditLog.resource,
|
||||
AuditLog.detail,
|
||||
AuditLog.ip_address,
|
||||
AuditLog.created_at,
|
||||
).outerjoin(User, AuditLog.user_id == User.id)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if resource:
|
||||
query = query.where(AuditLog.resource == resource)
|
||||
if start_time:
|
||||
try:
|
||||
st = datetime.fromisoformat(start_time)
|
||||
query = query.where(AuditLog.created_at >= st)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_time:
|
||||
try:
|
||||
et = datetime.fromisoformat(end_time)
|
||||
query = query.where(AuditLog.created_at <= et)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Count
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
# Paginate
|
||||
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
|
||||
items = []
|
||||
for row in result.all():
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"username": row.username or "-",
|
||||
"action": row.action,
|
||||
"resource": row.resource,
|
||||
"detail": row.detail,
|
||||
"ip_address": row.ip_address,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
})
|
||||
|
||||
return {"total": total, "items": items}
|
||||
53
backend/app/api/v1/auth.py
Normal file
53
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: dict
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
client_ip = request.client.host if request.client else None
|
||||
await log_audit(db, user.id, "login", "auth", detail=f"用户 {user.username} 登录", ip_address=client_ip)
|
||||
return Token(
|
||||
access_token=token,
|
||||
user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": user.id, "username": user.username, "full_name": user.full_name,
|
||||
"email": user.email, "phone": user.phone, "role": user.role, "is_active": user.is_active,
|
||||
}
|
||||
20
backend/app/api/v1/branding.py
Normal file
20
backend/app/api/v1/branding.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/branding", tags=["品牌配置"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_branding():
|
||||
"""Return customer-specific branding configuration"""
|
||||
settings = get_settings()
|
||||
customer_config = settings.load_customer_config()
|
||||
return {
|
||||
"customer": settings.CUSTOMER,
|
||||
"customer_name": customer_config.get("customer_name", settings.CUSTOMER),
|
||||
"platform_name": customer_config.get("platform_name", settings.APP_NAME),
|
||||
"platform_name_en": customer_config.get("platform_name_en", "Smart EMS"),
|
||||
"logo_url": customer_config.get("logo_url", ""),
|
||||
"theme_color": customer_config.get("theme_color", "#1890ff"),
|
||||
"features": customer_config.get("features", {}),
|
||||
}
|
||||
434
backend/app/api/v1/carbon.py
Normal file
434
backend/app/api/v1/carbon.py
Normal file
@@ -0,0 +1,434 @@
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.carbon import (
|
||||
CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction,
|
||||
GreenCertificate, CarbonReport, CarbonBenchmark,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.services import carbon_asset
|
||||
|
||||
router = APIRouter(prefix="/carbon", tags=["碳排放管理"])
|
||||
|
||||
|
||||
# --------------- Pydantic Schemas ---------------
|
||||
|
||||
class TargetCreate(BaseModel):
|
||||
year: int
|
||||
month: Optional[int] = None
|
||||
target_emission_tons: float
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
target_emission_tons: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class CertificateCreate(BaseModel):
|
||||
certificate_type: str
|
||||
certificate_number: str
|
||||
issue_date: date
|
||||
expiry_date: Optional[date] = None
|
||||
energy_mwh: float
|
||||
price_yuan: float = 0
|
||||
status: str = "active"
|
||||
source_device_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class CertificateUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
price_yuan: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class ReportGenerate(BaseModel):
|
||||
report_type: str = Field(..., pattern="^(monthly|quarterly|annual)$")
|
||||
period_start: date
|
||||
period_end: date
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""碳排放总览"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
async def sum_carbon(start, end):
|
||||
r = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||||
.where(and_(CarbonEmission.date >= start, CarbonEmission.date < end))
|
||||
)
|
||||
row = r.first()
|
||||
return {"emission": row[0] or 0, "reduction": row[1] or 0}
|
||||
|
||||
today = await sum_carbon(today_start, now)
|
||||
month = await sum_carbon(month_start, now)
|
||||
year = await sum_carbon(year_start, now)
|
||||
|
||||
# 各scope分布
|
||||
scope_q = await db.execute(
|
||||
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
||||
.where(CarbonEmission.date >= year_start)
|
||||
.group_by(CarbonEmission.scope)
|
||||
)
|
||||
by_scope = {row[0]: round(row[1], 2) for row in scope_q.all()}
|
||||
|
||||
return {
|
||||
"today": {"emission": round(today["emission"], 2), "reduction": round(today["reduction"], 2)},
|
||||
"month": {"emission": round(month["emission"], 2), "reduction": round(month["reduction"], 2)},
|
||||
"year": {"emission": round(year["emission"], 2), "reduction": round(year["reduction"], 2)},
|
||||
"by_scope": by_scope,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trend")
|
||||
async def carbon_trend(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""碳排放趋势"""
|
||||
start = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
settings = get_settings()
|
||||
if settings.is_sqlite:
|
||||
day_expr = func.strftime('%Y-%m-%d', CarbonEmission.date).label('day')
|
||||
else:
|
||||
day_expr = func.date_trunc('day', CarbonEmission.date).label('day')
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
day_expr,
|
||||
func.sum(CarbonEmission.emission),
|
||||
func.sum(CarbonEmission.reduction),
|
||||
).where(CarbonEmission.date >= start)
|
||||
.group_by(text('day')).order_by(text('day'))
|
||||
)
|
||||
return [{"date": str(r[0]), "emission": round(r[1], 2), "reduction": round(r[2], 2)} for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/factors")
|
||||
async def list_factors(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(EmissionFactor).order_by(EmissionFactor.id))
|
||||
return [{
|
||||
"id": f.id, "name": f.name, "energy_type": f.energy_type, "factor": f.factor,
|
||||
"unit": f.unit, "scope": f.scope, "region": f.region, "source": f.source,
|
||||
} for f in result.scalars().all()]
|
||||
|
||||
|
||||
# =============== Carbon Dashboard ===============
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def carbon_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""综合碳资产仪表盘"""
|
||||
return await carbon_asset.get_carbon_dashboard(db)
|
||||
|
||||
|
||||
# =============== Carbon Targets ===============
|
||||
|
||||
@router.get("/targets")
|
||||
async def list_targets(
|
||||
year: int = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""碳减排目标列表"""
|
||||
q = select(CarbonTarget).order_by(CarbonTarget.year.desc(), CarbonTarget.month)
|
||||
if year:
|
||||
q = q.where(CarbonTarget.year == year)
|
||||
result = await db.execute(q)
|
||||
targets = result.scalars().all()
|
||||
return [{
|
||||
"id": t.id, "year": t.year, "month": t.month,
|
||||
"target_emission_tons": t.target_emission_tons,
|
||||
"actual_emission_tons": t.actual_emission_tons,
|
||||
"status": t.status,
|
||||
} for t in targets]
|
||||
|
||||
|
||||
@router.post("/targets")
|
||||
async def create_target(
|
||||
data: TargetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建碳减排目标"""
|
||||
target = CarbonTarget(
|
||||
year=data.year,
|
||||
month=data.month,
|
||||
target_emission_tons=data.target_emission_tons,
|
||||
)
|
||||
db.add(target)
|
||||
await db.flush()
|
||||
return {"id": target.id, "message": "目标创建成功"}
|
||||
|
||||
|
||||
@router.put("/targets/{target_id}")
|
||||
async def update_target(
|
||||
target_id: int,
|
||||
data: TargetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新碳减排目标"""
|
||||
result = await db.execute(select(CarbonTarget).where(CarbonTarget.id == target_id))
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
if data.target_emission_tons is not None:
|
||||
target.target_emission_tons = data.target_emission_tons
|
||||
if data.status is not None:
|
||||
target.status = data.status
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.get("/targets/progress")
|
||||
async def target_progress(
|
||||
year: int = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""碳目标进度"""
|
||||
if year is None:
|
||||
year = datetime.now(timezone.utc).year
|
||||
return await carbon_asset.get_target_progress(db, year)
|
||||
|
||||
|
||||
# =============== Carbon Reductions ===============
|
||||
|
||||
@router.get("/reductions")
|
||||
async def list_reductions(
|
||||
start: date = Query(None),
|
||||
end: date = Query(None),
|
||||
source_type: str = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""碳减排活动列表"""
|
||||
q = select(CarbonReduction).order_by(CarbonReduction.date.desc())
|
||||
if start:
|
||||
q = q.where(CarbonReduction.date >= start)
|
||||
if end:
|
||||
q = q.where(CarbonReduction.date <= end)
|
||||
if source_type:
|
||||
q = q.where(CarbonReduction.source_type == source_type)
|
||||
result = await db.execute(q.limit(500))
|
||||
items = result.scalars().all()
|
||||
return [{
|
||||
"id": r.id, "source_type": r.source_type, "date": str(r.date),
|
||||
"reduction_tons": r.reduction_tons, "equivalent_trees": r.equivalent_trees,
|
||||
"methodology": r.methodology, "verified": r.verified,
|
||||
} for r in items]
|
||||
|
||||
|
||||
@router.get("/reductions/summary")
|
||||
async def reduction_summary(
|
||||
start: date = Query(None),
|
||||
end: date = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""减排汇总(按来源类型)"""
|
||||
if not start:
|
||||
start = date(datetime.now(timezone.utc).year, 1, 1)
|
||||
if not end:
|
||||
end = datetime.now(timezone.utc).date()
|
||||
return await carbon_asset.get_reduction_summary(db, start, end)
|
||||
|
||||
|
||||
@router.post("/reductions/calculate")
|
||||
async def calculate_reductions(
|
||||
start: date = Query(None),
|
||||
end: date = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发减排量计算"""
|
||||
if not start:
|
||||
start = date(datetime.now(timezone.utc).year, 1, 1)
|
||||
if not end:
|
||||
end = datetime.now(timezone.utc).date()
|
||||
return await carbon_asset.trigger_reduction_calculation(db, start, end)
|
||||
|
||||
|
||||
# =============== Green Certificates ===============
|
||||
|
||||
@router.get("/certificates")
|
||||
async def list_certificates(
|
||||
status: str = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""绿证列表"""
|
||||
q = select(GreenCertificate).order_by(GreenCertificate.issue_date.desc())
|
||||
if status:
|
||||
q = q.where(GreenCertificate.status == status)
|
||||
result = await db.execute(q)
|
||||
certs = result.scalars().all()
|
||||
return [{
|
||||
"id": c.id, "certificate_type": c.certificate_type,
|
||||
"certificate_number": c.certificate_number,
|
||||
"issue_date": str(c.issue_date), "expiry_date": str(c.expiry_date) if c.expiry_date else None,
|
||||
"energy_mwh": c.energy_mwh, "price_yuan": c.price_yuan,
|
||||
"status": c.status, "notes": c.notes,
|
||||
} for c in certs]
|
||||
|
||||
|
||||
@router.post("/certificates")
|
||||
async def create_certificate(
|
||||
data: CertificateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""登记绿证"""
|
||||
cert = GreenCertificate(**data.model_dump())
|
||||
db.add(cert)
|
||||
await db.flush()
|
||||
return {"id": cert.id, "message": "绿证登记成功"}
|
||||
|
||||
|
||||
@router.put("/certificates/{cert_id}")
|
||||
async def update_certificate(
|
||||
cert_id: int,
|
||||
data: CertificateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新绿证"""
|
||||
result = await db.execute(select(GreenCertificate).where(GreenCertificate.id == cert_id))
|
||||
cert = result.scalar_one_or_none()
|
||||
if not cert:
|
||||
raise HTTPException(404, "绿证不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cert, k, v)
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.get("/certificates/value")
|
||||
async def certificate_portfolio_value(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""绿证组合价值"""
|
||||
return await carbon_asset.get_certificate_portfolio_value(db)
|
||||
|
||||
|
||||
# =============== Carbon Reports ===============
|
||||
|
||||
@router.get("/reports")
|
||||
async def list_reports(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""报告列表"""
|
||||
result = await db.execute(
|
||||
select(CarbonReport).order_by(CarbonReport.generated_at.desc()).limit(50)
|
||||
)
|
||||
reports = result.scalars().all()
|
||||
return [{
|
||||
"id": r.id, "report_type": r.report_type,
|
||||
"period_start": str(r.period_start), "period_end": str(r.period_end),
|
||||
"generated_at": str(r.generated_at),
|
||||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||||
"net_tons": r.net_tons,
|
||||
} for r in reports]
|
||||
|
||||
|
||||
@router.post("/reports/generate")
|
||||
async def generate_report(
|
||||
data: ReportGenerate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""生成碳报告"""
|
||||
report = await carbon_asset.generate_carbon_report(
|
||||
db, data.report_type, data.period_start, data.period_end,
|
||||
)
|
||||
await db.flush()
|
||||
return {
|
||||
"id": report.id,
|
||||
"total_tons": report.total_tons,
|
||||
"reduction_tons": report.reduction_tons,
|
||||
"net_tons": report.net_tons,
|
||||
"message": "报告生成成功",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
async def get_report(
|
||||
report_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""报告详情"""
|
||||
result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id))
|
||||
r = result.scalar_one_or_none()
|
||||
if not r:
|
||||
raise HTTPException(404, "报告不存在")
|
||||
return {
|
||||
"id": r.id, "report_type": r.report_type,
|
||||
"period_start": str(r.period_start), "period_end": str(r.period_end),
|
||||
"generated_at": str(r.generated_at),
|
||||
"scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons,
|
||||
"scope3_tons": r.scope3_tons,
|
||||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||||
"net_tons": r.net_tons, "report_data": r.report_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/download")
|
||||
async def download_report(
|
||||
report_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""下载报告数据"""
|
||||
result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id))
|
||||
r = result.scalar_one_or_none()
|
||||
if not r:
|
||||
raise HTTPException(404, "报告不存在")
|
||||
return {
|
||||
"report_type": r.report_type,
|
||||
"period": f"{r.period_start} ~ {r.period_end}",
|
||||
"scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons,
|
||||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||||
"net_tons": r.net_tons,
|
||||
"detail": r.report_data,
|
||||
}
|
||||
|
||||
|
||||
# =============== Carbon Benchmarks ===============
|
||||
|
||||
@router.get("/benchmarks")
|
||||
async def list_benchmarks(
|
||||
year: int = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""行业基准列表"""
|
||||
q = select(CarbonBenchmark).order_by(CarbonBenchmark.year.desc())
|
||||
if year:
|
||||
q = q.where(CarbonBenchmark.year == year)
|
||||
result = await db.execute(q)
|
||||
items = result.scalars().all()
|
||||
return [{
|
||||
"id": b.id, "industry": b.industry, "metric_name": b.metric_name,
|
||||
"benchmark_value": b.benchmark_value, "unit": b.unit,
|
||||
"year": b.year, "source": b.source, "notes": b.notes,
|
||||
} for b in items]
|
||||
|
||||
|
||||
@router.get("/benchmarks/comparison")
|
||||
async def benchmark_comparison(
|
||||
year: int = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""行业对标比较"""
|
||||
if year is None:
|
||||
year = datetime.now(timezone.utc).year
|
||||
return await carbon_asset.compare_with_benchmarks(db, year)
|
||||
716
backend/app/api/v1/charging.py
Normal file
716
backend/app/api/v1/charging.py
Normal file
@@ -0,0 +1,716 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.charging import (
|
||||
ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam,
|
||||
ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/charging", tags=["充电管理"])
|
||||
|
||||
|
||||
# ─── Pydantic Schemas ───────────────────────────────────────────────
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
name: str
|
||||
merchant_id: int | None = None
|
||||
type: str | None = None
|
||||
address: str | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
price: float | None = None
|
||||
activity: str | None = None
|
||||
status: str = "active"
|
||||
total_piles: int = 0
|
||||
available_piles: int = 0
|
||||
total_power_kw: float = 0
|
||||
photo_url: str | None = None
|
||||
operating_hours: str | None = None
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
merchant_id: int | None = None
|
||||
type: str | None = None
|
||||
address: str | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
price: float | None = None
|
||||
activity: str | None = None
|
||||
status: str | None = None
|
||||
total_piles: int | None = None
|
||||
available_piles: int | None = None
|
||||
total_power_kw: float | None = None
|
||||
photo_url: str | None = None
|
||||
operating_hours: str | None = None
|
||||
|
||||
|
||||
class PileCreate(BaseModel):
|
||||
station_id: int
|
||||
encoding: str
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
brand: str | None = None
|
||||
model: str | None = None
|
||||
rated_power_kw: float | None = None
|
||||
connector_type: str | None = None
|
||||
status: str = "active"
|
||||
work_status: str = "offline"
|
||||
|
||||
|
||||
class PileUpdate(BaseModel):
|
||||
station_id: int | None = None
|
||||
encoding: str | None = None
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
brand: str | None = None
|
||||
model: str | None = None
|
||||
rated_power_kw: float | None = None
|
||||
connector_type: str | None = None
|
||||
status: str | None = None
|
||||
work_status: str | None = None
|
||||
|
||||
|
||||
class PriceParamCreate(BaseModel):
|
||||
start_time: str
|
||||
end_time: str
|
||||
period_mark: str | None = None
|
||||
elec_price: float
|
||||
service_price: float = 0
|
||||
|
||||
|
||||
class PriceStrategyCreate(BaseModel):
|
||||
strategy_name: str
|
||||
station_id: int | None = None
|
||||
bill_model: str | None = None
|
||||
description: str | None = None
|
||||
status: str = "inactive"
|
||||
params: list[PriceParamCreate] = []
|
||||
|
||||
|
||||
class PriceStrategyUpdate(BaseModel):
|
||||
strategy_name: str | None = None
|
||||
station_id: int | None = None
|
||||
bill_model: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
params: list[PriceParamCreate] | None = None
|
||||
|
||||
|
||||
class MerchantCreate(BaseModel):
|
||||
name: str
|
||||
contact_person: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
address: str | None = None
|
||||
business_license: str | None = None
|
||||
status: str = "active"
|
||||
settlement_type: str | None = None
|
||||
|
||||
|
||||
class BrandCreate(BaseModel):
|
||||
brand_name: str
|
||||
logo_url: str | None = None
|
||||
country: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
# ─── Station Endpoints ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/stations")
|
||||
async def list_stations(
|
||||
status: str | None = None,
|
||||
type: str | None = None,
|
||||
merchant_id: int | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ChargingStation)
|
||||
if status:
|
||||
query = query.where(ChargingStation.status == status)
|
||||
if type:
|
||||
query = query.where(ChargingStation.type == type)
|
||||
if merchant_id:
|
||||
query = query.where(ChargingStation.merchant_id == merchant_id)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(ChargingStation.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
stations = result.scalars().all()
|
||||
return {"total": total, "items": [_station_to_dict(s) for s in stations]}
|
||||
|
||||
|
||||
@router.post("/stations")
|
||||
async def create_station(
|
||||
data: StationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
station = ChargingStation(**data.model_dump(), created_by=user.id)
|
||||
db.add(station)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "charging", detail=f"创建充电站 {data.name}")
|
||||
return _station_to_dict(station)
|
||||
|
||||
|
||||
@router.put("/stations/{station_id}")
|
||||
async def update_station(
|
||||
station_id: int,
|
||||
data: StationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id))
|
||||
station = result.scalar_one_or_none()
|
||||
if not station:
|
||||
raise HTTPException(status_code=404, detail="充电站不存在")
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(station, k, v)
|
||||
await log_audit(db, user.id, "update", "charging", detail=f"更新充电站 {station.name}")
|
||||
return _station_to_dict(station)
|
||||
|
||||
|
||||
@router.delete("/stations/{station_id}")
|
||||
async def delete_station(
|
||||
station_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id))
|
||||
station = result.scalar_one_or_none()
|
||||
if not station:
|
||||
raise HTTPException(status_code=404, detail="充电站不存在")
|
||||
station.status = "disabled"
|
||||
await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电站 {station.name}")
|
||||
return {"message": "已禁用"}
|
||||
|
||||
|
||||
@router.get("/stations/{station_id}/piles")
|
||||
async def list_station_piles(
|
||||
station_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ChargingPile).where(ChargingPile.station_id == station_id).order_by(ChargingPile.id)
|
||||
)
|
||||
return [_pile_to_dict(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
# ─── Pile Endpoints ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/piles")
|
||||
async def list_piles(
|
||||
station_id: int | None = None,
|
||||
status: str | None = None,
|
||||
work_status: str | None = None,
|
||||
type: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ChargingPile)
|
||||
if station_id:
|
||||
query = query.where(ChargingPile.station_id == station_id)
|
||||
if status:
|
||||
query = query.where(ChargingPile.status == status)
|
||||
if work_status:
|
||||
query = query.where(ChargingPile.work_status == work_status)
|
||||
if type:
|
||||
query = query.where(ChargingPile.type == type)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(ChargingPile.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
piles = result.scalars().all()
|
||||
return {"total": total, "items": [_pile_to_dict(p) for p in piles]}
|
||||
|
||||
|
||||
@router.post("/piles")
|
||||
async def create_pile(
|
||||
data: PileCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
pile = ChargingPile(**data.model_dump())
|
||||
db.add(pile)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "charging", detail=f"创建充电桩 {data.encoding}")
|
||||
return _pile_to_dict(pile)
|
||||
|
||||
|
||||
@router.put("/piles/{pile_id}")
|
||||
async def update_pile(
|
||||
pile_id: int,
|
||||
data: PileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id))
|
||||
pile = result.scalar_one_or_none()
|
||||
if not pile:
|
||||
raise HTTPException(status_code=404, detail="充电桩不存在")
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(pile, k, v)
|
||||
await log_audit(db, user.id, "update", "charging", detail=f"更新充电桩 {pile.encoding}")
|
||||
return _pile_to_dict(pile)
|
||||
|
||||
|
||||
@router.delete("/piles/{pile_id}")
|
||||
async def delete_pile(
|
||||
pile_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id))
|
||||
pile = result.scalar_one_or_none()
|
||||
if not pile:
|
||||
raise HTTPException(status_code=404, detail="充电桩不存在")
|
||||
pile.status = "disabled"
|
||||
await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电桩 {pile.encoding}")
|
||||
return {"message": "已禁用"}
|
||||
|
||||
|
||||
# ─── Pricing Endpoints ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/pricing")
|
||||
async def list_pricing(
|
||||
station_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ChargingPriceStrategy)
|
||||
if station_id:
|
||||
query = query.where(ChargingPriceStrategy.station_id == station_id)
|
||||
result = await db.execute(query.order_by(ChargingPriceStrategy.id.desc()))
|
||||
strategies = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for s in strategies:
|
||||
params_q = await db.execute(
|
||||
select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == s.id).order_by(ChargingPriceParam.start_time)
|
||||
)
|
||||
params = [_param_to_dict(p) for p in params_q.scalars().all()]
|
||||
d = _strategy_to_dict(s)
|
||||
d["params"] = params
|
||||
items.append(d)
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/pricing")
|
||||
async def create_pricing(
|
||||
data: PriceStrategyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
strategy = ChargingPriceStrategy(
|
||||
strategy_name=data.strategy_name,
|
||||
station_id=data.station_id,
|
||||
bill_model=data.bill_model,
|
||||
description=data.description,
|
||||
status=data.status,
|
||||
)
|
||||
db.add(strategy)
|
||||
await db.flush()
|
||||
|
||||
for p in data.params:
|
||||
param = ChargingPriceParam(strategy_id=strategy.id, **p.model_dump())
|
||||
db.add(param)
|
||||
await db.flush()
|
||||
|
||||
await log_audit(db, user.id, "create", "charging", detail=f"创建计费策略 {data.strategy_name}")
|
||||
return _strategy_to_dict(strategy)
|
||||
|
||||
|
||||
@router.put("/pricing/{strategy_id}")
|
||||
async def update_pricing(
|
||||
strategy_id: int,
|
||||
data: PriceStrategyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id))
|
||||
strategy = result.scalar_one_or_none()
|
||||
if not strategy:
|
||||
raise HTTPException(status_code=404, detail="计费策略不存在")
|
||||
|
||||
updates = data.model_dump(exclude_unset=True, exclude={"params"})
|
||||
for k, v in updates.items():
|
||||
setattr(strategy, k, v)
|
||||
|
||||
if data.params is not None:
|
||||
# Delete old params and recreate
|
||||
old_params = await db.execute(
|
||||
select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == strategy_id)
|
||||
)
|
||||
for old in old_params.scalars().all():
|
||||
await db.delete(old)
|
||||
await db.flush()
|
||||
|
||||
for p in data.params:
|
||||
param = ChargingPriceParam(strategy_id=strategy_id, **p.model_dump())
|
||||
db.add(param)
|
||||
await db.flush()
|
||||
|
||||
await log_audit(db, user.id, "update", "charging", detail=f"更新计费策略 {strategy.strategy_name}")
|
||||
return _strategy_to_dict(strategy)
|
||||
|
||||
|
||||
@router.delete("/pricing/{strategy_id}")
|
||||
async def delete_pricing(
|
||||
strategy_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id))
|
||||
strategy = result.scalar_one_or_none()
|
||||
if not strategy:
|
||||
raise HTTPException(status_code=404, detail="计费策略不存在")
|
||||
strategy.status = "inactive"
|
||||
await log_audit(db, user.id, "delete", "charging", detail=f"停用计费策略 {strategy.strategy_name}")
|
||||
return {"message": "已停用"}
|
||||
|
||||
|
||||
# ─── Order Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/orders")
|
||||
async def list_orders(
|
||||
order_status: str | None = None,
|
||||
station_id: int | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ChargingOrder)
|
||||
if order_status:
|
||||
query = query.where(ChargingOrder.order_status == order_status)
|
||||
if station_id:
|
||||
query = query.where(ChargingOrder.station_id == station_id)
|
||||
if start_date:
|
||||
query = query.where(ChargingOrder.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ChargingOrder.created_at <= end_date)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
orders = result.scalars().all()
|
||||
return {"total": total, "items": [_order_to_dict(o) for o in orders]}
|
||||
|
||||
|
||||
@router.get("/orders/realtime")
|
||||
async def realtime_orders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ChargingOrder).where(ChargingOrder.order_status == "charging").order_by(ChargingOrder.start_time.desc())
|
||||
)
|
||||
return [_order_to_dict(o) for o in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/orders/abnormal")
|
||||
async def abnormal_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ChargingOrder).where(ChargingOrder.order_status.in_(["failed", "refunded"]))
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {"total": total, "items": [_order_to_dict(o) for o in result.scalars().all()]}
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
async def get_order(
|
||||
order_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
return _order_to_dict(order)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/settle")
|
||||
async def settle_order(
|
||||
order_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
order.settle_type = "manual"
|
||||
order.settle_time = datetime.now(timezone.utc)
|
||||
order.order_status = "completed"
|
||||
await log_audit(db, user.id, "update", "charging", detail=f"手动结算订单 {order.order_no}")
|
||||
return {"message": "已结算"}
|
||||
|
||||
|
||||
# ─── Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def charging_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Total revenue (completed orders)
|
||||
rev_q = await db.execute(
|
||||
select(func.sum(ChargingOrder.paid_price)).where(ChargingOrder.order_status == "completed")
|
||||
)
|
||||
total_revenue = rev_q.scalar() or 0
|
||||
|
||||
# Total energy delivered
|
||||
energy_q = await db.execute(
|
||||
select(func.sum(ChargingOrder.energy)).where(ChargingOrder.order_status == "completed")
|
||||
)
|
||||
total_energy = energy_q.scalar() or 0
|
||||
|
||||
# Active sessions
|
||||
active_q = await db.execute(
|
||||
select(func.count(ChargingOrder.id)).where(ChargingOrder.order_status == "charging")
|
||||
)
|
||||
active_sessions = active_q.scalar() or 0
|
||||
|
||||
# Utilization rate: charging piles / total active piles
|
||||
total_piles_q = await db.execute(
|
||||
select(func.count(ChargingPile.id)).where(ChargingPile.status == "active")
|
||||
)
|
||||
total_piles = total_piles_q.scalar() or 0
|
||||
charging_piles_q = await db.execute(
|
||||
select(func.count(ChargingPile.id)).where(ChargingPile.work_status == "charging")
|
||||
)
|
||||
charging_piles = charging_piles_q.scalar() or 0
|
||||
utilization_rate = round(charging_piles / total_piles * 100, 1) if total_piles > 0 else 0
|
||||
|
||||
# Revenue trend (last 30 days)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
trend_q = await db.execute(
|
||||
select(
|
||||
func.date(ChargingOrder.created_at).label("date"),
|
||||
func.sum(ChargingOrder.paid_price).label("revenue"),
|
||||
func.sum(ChargingOrder.energy).label("energy"),
|
||||
).where(
|
||||
and_(ChargingOrder.order_status == "completed", ChargingOrder.created_at >= thirty_days_ago)
|
||||
).group_by(func.date(ChargingOrder.created_at)).order_by(func.date(ChargingOrder.created_at))
|
||||
)
|
||||
revenue_trend = [{"date": str(r[0]), "revenue": round(r[1] or 0, 2), "energy": round(r[2] or 0, 2)} for r in trend_q.all()]
|
||||
|
||||
# Station ranking by revenue
|
||||
ranking_q = await db.execute(
|
||||
select(
|
||||
ChargingOrder.station_name,
|
||||
func.sum(ChargingOrder.paid_price).label("revenue"),
|
||||
func.count(ChargingOrder.id).label("orders"),
|
||||
).where(ChargingOrder.order_status == "completed")
|
||||
.group_by(ChargingOrder.station_name)
|
||||
.order_by(func.sum(ChargingOrder.paid_price).desc())
|
||||
.limit(10)
|
||||
)
|
||||
station_ranking = [{"station": r[0] or "未知", "revenue": round(r[1] or 0, 2), "orders": r[2]} for r in ranking_q.all()]
|
||||
|
||||
# Pile status distribution
|
||||
pile_status_q = await db.execute(
|
||||
select(ChargingPile.work_status, func.count(ChargingPile.id))
|
||||
.where(ChargingPile.status == "active")
|
||||
.group_by(ChargingPile.work_status)
|
||||
)
|
||||
pile_status = {row[0]: row[1] for row in pile_status_q.all()}
|
||||
|
||||
return {
|
||||
"total_revenue": round(total_revenue, 2),
|
||||
"total_energy": round(total_energy, 2),
|
||||
"active_sessions": active_sessions,
|
||||
"utilization_rate": utilization_rate,
|
||||
"revenue_trend": revenue_trend,
|
||||
"station_ranking": station_ranking,
|
||||
"pile_status": {
|
||||
"idle": pile_status.get("idle", 0),
|
||||
"charging": pile_status.get("charging", 0),
|
||||
"fault": pile_status.get("fault", 0),
|
||||
"offline": pile_status.get("offline", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── Merchant CRUD ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/merchants")
|
||||
async def list_merchants(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ChargingMerchant).order_by(ChargingMerchant.id.desc()))
|
||||
return [_merchant_to_dict(m) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/merchants")
|
||||
async def create_merchant(data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
merchant = ChargingMerchant(**data.model_dump())
|
||||
db.add(merchant)
|
||||
await db.flush()
|
||||
return _merchant_to_dict(merchant)
|
||||
|
||||
|
||||
@router.put("/merchants/{merchant_id}")
|
||||
async def update_merchant(merchant_id: int, data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id))
|
||||
merchant = result.scalar_one_or_none()
|
||||
if not merchant:
|
||||
raise HTTPException(status_code=404, detail="运营商不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(merchant, k, v)
|
||||
return _merchant_to_dict(merchant)
|
||||
|
||||
|
||||
@router.delete("/merchants/{merchant_id}")
|
||||
async def delete_merchant(merchant_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id))
|
||||
merchant = result.scalar_one_or_none()
|
||||
if not merchant:
|
||||
raise HTTPException(status_code=404, detail="运营商不存在")
|
||||
merchant.status = "disabled"
|
||||
return {"message": "已禁用"}
|
||||
|
||||
|
||||
# ─── Brand CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/brands")
|
||||
async def list_brands(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ChargingBrand).order_by(ChargingBrand.id.desc()))
|
||||
return [_brand_to_dict(b) for b in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/brands")
|
||||
async def create_brand(data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
brand = ChargingBrand(**data.model_dump())
|
||||
db.add(brand)
|
||||
await db.flush()
|
||||
return _brand_to_dict(brand)
|
||||
|
||||
|
||||
@router.put("/brands/{brand_id}")
|
||||
async def update_brand(brand_id: int, data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id))
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(status_code=404, detail="品牌不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(brand, k, v)
|
||||
return _brand_to_dict(brand)
|
||||
|
||||
|
||||
@router.delete("/brands/{brand_id}")
|
||||
async def delete_brand(brand_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id))
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(status_code=404, detail="品牌不存在")
|
||||
await db.delete(brand)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ─── Dict Helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _station_to_dict(s: ChargingStation) -> dict:
|
||||
return {
|
||||
"id": s.id, "name": s.name, "merchant_id": s.merchant_id, "type": s.type,
|
||||
"address": s.address, "latitude": s.latitude, "longitude": s.longitude,
|
||||
"price": s.price, "activity": s.activity, "status": s.status,
|
||||
"total_piles": s.total_piles, "available_piles": s.available_piles,
|
||||
"total_power_kw": s.total_power_kw, "photo_url": s.photo_url,
|
||||
"operating_hours": s.operating_hours, "created_by": s.created_by,
|
||||
"created_at": str(s.created_at) if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _pile_to_dict(p: ChargingPile) -> dict:
|
||||
return {
|
||||
"id": p.id, "station_id": p.station_id, "encoding": p.encoding,
|
||||
"name": p.name, "type": p.type, "brand": p.brand, "model": p.model,
|
||||
"rated_power_kw": p.rated_power_kw, "connector_type": p.connector_type,
|
||||
"status": p.status, "work_status": p.work_status,
|
||||
"created_at": str(p.created_at) if p.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _strategy_to_dict(s: ChargingPriceStrategy) -> dict:
|
||||
return {
|
||||
"id": s.id, "strategy_name": s.strategy_name, "station_id": s.station_id,
|
||||
"bill_model": s.bill_model, "description": s.description, "status": s.status,
|
||||
"created_at": str(s.created_at) if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _param_to_dict(p: ChargingPriceParam) -> dict:
|
||||
return {
|
||||
"id": p.id, "strategy_id": p.strategy_id, "start_time": p.start_time,
|
||||
"end_time": p.end_time, "period_mark": p.period_mark,
|
||||
"elec_price": p.elec_price, "service_price": p.service_price,
|
||||
}
|
||||
|
||||
|
||||
def _order_to_dict(o: ChargingOrder) -> dict:
|
||||
return {
|
||||
"id": o.id, "order_no": o.order_no, "user_id": o.user_id,
|
||||
"user_name": o.user_name, "phone": o.phone,
|
||||
"station_id": o.station_id, "station_name": o.station_name,
|
||||
"pile_id": o.pile_id, "pile_name": o.pile_name,
|
||||
"start_time": str(o.start_time) if o.start_time else None,
|
||||
"end_time": str(o.end_time) if o.end_time else None,
|
||||
"car_no": o.car_no, "car_vin": o.car_vin,
|
||||
"charge_method": o.charge_method, "settle_type": o.settle_type,
|
||||
"pay_type": o.pay_type,
|
||||
"settle_time": str(o.settle_time) if o.settle_time else None,
|
||||
"settle_price": o.settle_price, "paid_price": o.paid_price,
|
||||
"discount_amt": o.discount_amt, "elec_amt": o.elec_amt,
|
||||
"serve_amt": o.serve_amt, "order_status": o.order_status,
|
||||
"charge_duration": o.charge_duration, "energy": o.energy,
|
||||
"start_soc": o.start_soc, "end_soc": o.end_soc,
|
||||
"abno_cause": o.abno_cause, "order_source": o.order_source,
|
||||
"created_at": str(o.created_at) if o.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _merchant_to_dict(m: ChargingMerchant) -> dict:
|
||||
return {
|
||||
"id": m.id, "name": m.name, "contact_person": m.contact_person,
|
||||
"phone": m.phone, "email": m.email, "address": m.address,
|
||||
"business_license": m.business_license, "status": m.status,
|
||||
"settlement_type": m.settlement_type,
|
||||
}
|
||||
|
||||
|
||||
def _brand_to_dict(b: ChargingBrand) -> dict:
|
||||
return {
|
||||
"id": b.id, "brand_name": b.brand_name, "logo_url": b.logo_url,
|
||||
"country": b.country, "description": b.description,
|
||||
}
|
||||
53
backend/app/api/v1/collectors.py
Normal file
53
backend/app/api/v1/collectors.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""API endpoints for collector management and status."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/collectors", tags=["collectors"])
|
||||
|
||||
|
||||
def _get_manager():
|
||||
"""Get the global CollectorManager instance."""
|
||||
from app.main import collector_manager
|
||||
if collector_manager is None:
|
||||
raise HTTPException(status_code=503, detail="Collector manager not active (simulator mode)")
|
||||
return collector_manager
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_collectors_status():
|
||||
"""Get status of all active collectors."""
|
||||
manager = _get_manager()
|
||||
return {
|
||||
"running": manager.is_running,
|
||||
"collector_count": manager.collector_count,
|
||||
"collectors": manager.get_all_status(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status/{device_id}")
|
||||
async def get_collector_status(device_id: int):
|
||||
"""Get status of a specific collector."""
|
||||
manager = _get_manager()
|
||||
collector = manager.get_collector(device_id)
|
||||
if not collector:
|
||||
raise HTTPException(status_code=404, detail="No collector for this device")
|
||||
return collector.get_status()
|
||||
|
||||
|
||||
@router.post("/{device_id}/restart")
|
||||
async def restart_collector(device_id: int):
|
||||
"""Restart a specific device collector."""
|
||||
manager = _get_manager()
|
||||
success = await manager.restart_collector(device_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to restart collector")
|
||||
return {"message": f"Collector for device {device_id} restarted"}
|
||||
|
||||
|
||||
@router.post("/{device_id}/stop")
|
||||
async def stop_collector(device_id: int):
|
||||
"""Stop a specific device collector."""
|
||||
manager = _get_manager()
|
||||
success = await manager.stop_collector(device_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="No running collector for this device")
|
||||
return {"message": f"Collector for device {device_id} stopped"}
|
||||
279
backend/app/api/v1/cost.py
Normal file
279
backend/app/api/v1/cost.py
Normal file
@@ -0,0 +1,279 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.pricing import ElectricityPricing, PricingPeriod
|
||||
from app.models.energy import EnergyDailySummary
|
||||
from app.models.user import User
|
||||
from app.services.cost_calculator import get_cost_summary, get_cost_breakdown
|
||||
|
||||
router = APIRouter(prefix="/cost", tags=["费用分析"])
|
||||
|
||||
|
||||
# ---- Schemas ----
|
||||
|
||||
class PricingPeriodCreate(BaseModel):
|
||||
period_name: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
price_per_unit: float
|
||||
applicable_months: list[int] | None = None
|
||||
|
||||
|
||||
class PricingCreate(BaseModel):
|
||||
name: str
|
||||
energy_type: str = "electricity"
|
||||
pricing_type: str # flat, tou, tiered
|
||||
effective_from: str | None = None
|
||||
effective_to: str | None = None
|
||||
periods: list[PricingPeriodCreate] = []
|
||||
|
||||
|
||||
class PricingUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
energy_type: str | None = None
|
||||
pricing_type: str | None = None
|
||||
effective_from: str | None = None
|
||||
effective_to: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ---- Pricing CRUD ----
|
||||
|
||||
@router.get("/pricing")
|
||||
async def list_pricing(
|
||||
energy_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取电价配置列表"""
|
||||
q = select(ElectricityPricing).order_by(ElectricityPricing.created_at.desc())
|
||||
if energy_type:
|
||||
q = q.where(ElectricityPricing.energy_type == energy_type)
|
||||
result = await db.execute(q)
|
||||
pricings = result.scalars().all()
|
||||
items = []
|
||||
for p in pricings:
|
||||
# Load periods
|
||||
pq = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == p.id))
|
||||
periods = pq.scalars().all()
|
||||
items.append({
|
||||
"id": p.id, "name": p.name, "energy_type": p.energy_type,
|
||||
"pricing_type": p.pricing_type, "is_active": p.is_active,
|
||||
"effective_from": str(p.effective_from) if p.effective_from else None,
|
||||
"effective_to": str(p.effective_to) if p.effective_to else None,
|
||||
"created_at": str(p.created_at),
|
||||
"periods": [
|
||||
{"id": pp.id, "period_name": pp.period_name, "start_time": pp.start_time,
|
||||
"end_time": pp.end_time, "price_per_unit": pp.price_per_unit,
|
||||
"applicable_months": pp.applicable_months}
|
||||
for pp in periods
|
||||
],
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/pricing")
|
||||
async def create_pricing(
|
||||
data: PricingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建电价配置"""
|
||||
pricing = ElectricityPricing(
|
||||
name=data.name,
|
||||
energy_type=data.energy_type,
|
||||
pricing_type=data.pricing_type,
|
||||
effective_from=datetime.fromisoformat(data.effective_from) if data.effective_from else None,
|
||||
effective_to=datetime.fromisoformat(data.effective_to) if data.effective_to else None,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(pricing)
|
||||
await db.flush()
|
||||
|
||||
for period in data.periods:
|
||||
pp = PricingPeriod(
|
||||
pricing_id=pricing.id,
|
||||
period_name=period.period_name,
|
||||
start_time=period.start_time,
|
||||
end_time=period.end_time,
|
||||
price_per_unit=period.price_per_unit,
|
||||
applicable_months=period.applicable_months,
|
||||
)
|
||||
db.add(pp)
|
||||
|
||||
return {"id": pricing.id, "message": "电价配置创建成功"}
|
||||
|
||||
|
||||
@router.put("/pricing/{pricing_id}")
|
||||
async def update_pricing(
|
||||
pricing_id: int,
|
||||
data: PricingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新电价配置"""
|
||||
result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id))
|
||||
pricing = result.scalar_one_or_none()
|
||||
if not pricing:
|
||||
raise HTTPException(status_code=404, detail="电价配置不存在")
|
||||
|
||||
if data.name is not None:
|
||||
pricing.name = data.name
|
||||
if data.energy_type is not None:
|
||||
pricing.energy_type = data.energy_type
|
||||
if data.pricing_type is not None:
|
||||
pricing.pricing_type = data.pricing_type
|
||||
if data.effective_from is not None:
|
||||
pricing.effective_from = datetime.fromisoformat(data.effective_from)
|
||||
if data.effective_to is not None:
|
||||
pricing.effective_to = datetime.fromisoformat(data.effective_to)
|
||||
if data.is_active is not None:
|
||||
pricing.is_active = data.is_active
|
||||
|
||||
return {"message": "电价配置更新成功"}
|
||||
|
||||
|
||||
@router.delete("/pricing/{pricing_id}")
|
||||
async def deactivate_pricing(
|
||||
pricing_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""停用电价配置"""
|
||||
result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id))
|
||||
pricing = result.scalar_one_or_none()
|
||||
if not pricing:
|
||||
raise HTTPException(status_code=404, detail="电价配置不存在")
|
||||
pricing.is_active = False
|
||||
return {"message": "电价配置已停用"}
|
||||
|
||||
|
||||
# ---- Pricing Periods ----
|
||||
|
||||
@router.get("/pricing/{pricing_id}/periods")
|
||||
async def list_periods(
|
||||
pricing_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取电价时段列表"""
|
||||
result = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == pricing_id))
|
||||
periods = result.scalars().all()
|
||||
return [
|
||||
{"id": p.id, "period_name": p.period_name, "start_time": p.start_time,
|
||||
"end_time": p.end_time, "price_per_unit": p.price_per_unit,
|
||||
"applicable_months": p.applicable_months}
|
||||
for p in periods
|
||||
]
|
||||
|
||||
|
||||
@router.post("/pricing/{pricing_id}/periods")
|
||||
async def add_period(
|
||||
pricing_id: int,
|
||||
data: PricingPeriodCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""添加电价时段"""
|
||||
result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="电价配置不存在")
|
||||
|
||||
period = PricingPeriod(
|
||||
pricing_id=pricing_id,
|
||||
period_name=data.period_name,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
price_per_unit=data.price_per_unit,
|
||||
applicable_months=data.applicable_months,
|
||||
)
|
||||
db.add(period)
|
||||
await db.flush()
|
||||
return {"id": period.id, "message": "时段添加成功"}
|
||||
|
||||
|
||||
# ---- Cost Analysis ----
|
||||
|
||||
@router.get("/summary")
|
||||
async def cost_summary(
|
||||
start_date: str = Query(..., description="开始日期, e.g. 2026-01-01"),
|
||||
end_date: str = Query(..., description="结束日期, e.g. 2026-03-31"),
|
||||
group_by: str = Query("day", pattern="^(day|month|device)$"),
|
||||
energy_type: str = Query("electricity"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""费用汇总"""
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
return await get_cost_summary(db, start_dt, end_dt, group_by, energy_type)
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
async def cost_comparison(
|
||||
energy_type: str = "electricity",
|
||||
period: str = Query("month", pattern="^(day|week|month|year)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""费用同比环比"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if period == "day":
|
||||
current_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(days=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "week":
|
||||
current_start = now - timedelta(days=now.weekday())
|
||||
current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(weeks=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "month":
|
||||
current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = (current_start - timedelta(days=1)).replace(day=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
else: # year
|
||||
current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start.replace(year=current_start.year - 1)
|
||||
yoy_start = prev_start
|
||||
|
||||
async def sum_cost(start, end):
|
||||
q = select(func.sum(EnergyDailySummary.cost)).where(
|
||||
and_(
|
||||
EnergyDailySummary.date >= start,
|
||||
EnergyDailySummary.date < end,
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
)
|
||||
)
|
||||
r = await db.execute(q)
|
||||
return r.scalar() or 0
|
||||
|
||||
current = await sum_cost(current_start, now)
|
||||
previous = await sum_cost(prev_start, current_start)
|
||||
yoy = await sum_cost(yoy_start, yoy_start.replace(year=yoy_start.year + 1))
|
||||
|
||||
return {
|
||||
"current": round(current, 2),
|
||||
"previous": round(previous, 2),
|
||||
"yoy": round(yoy, 2),
|
||||
"mom_change": round((current - previous) / previous * 100, 1) if previous else 0,
|
||||
"yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/breakdown")
|
||||
async def cost_breakdown_api(
|
||||
start_date: str = Query(..., description="开始日期"),
|
||||
end_date: str = Query(..., description="结束日期"),
|
||||
energy_type: str = Query("electricity"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""峰谷平费用分布"""
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
return await get_cost_breakdown(db, start_dt, end_dt, energy_type)
|
||||
146
backend/app/api/v1/dashboard.py
Normal file
146
backend/app/api/v1/dashboard.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text, case, literal_column
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData, EnergyDailySummary
|
||||
from app.models.alarm import AlarmEvent
|
||||
from app.models.carbon import CarbonEmission
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["大屏数据"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""能源总览大屏核心数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# 设备状态统计
|
||||
device_stats_q = await db.execute(
|
||||
select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status)
|
||||
)
|
||||
device_stats = {row[0]: row[1] for row in device_stats_q.all()}
|
||||
|
||||
# 今日能耗汇总
|
||||
daily_q = await db.execute(
|
||||
select(
|
||||
EnergyDailySummary.energy_type,
|
||||
func.sum(EnergyDailySummary.total_consumption),
|
||||
func.sum(EnergyDailySummary.total_generation),
|
||||
).where(EnergyDailySummary.date >= today_start).group_by(EnergyDailySummary.energy_type)
|
||||
)
|
||||
energy_summary = {}
|
||||
for row in daily_q.all():
|
||||
energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0}
|
||||
|
||||
# 今日碳排放
|
||||
carbon_q = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||||
.where(CarbonEmission.date >= today_start)
|
||||
)
|
||||
carbon_row = carbon_q.first()
|
||||
|
||||
# 活跃告警数
|
||||
alarm_count_q = await db.execute(
|
||||
select(func.count(AlarmEvent.id)).where(AlarmEvent.status == "active")
|
||||
)
|
||||
active_alarms = alarm_count_q.scalar() or 0
|
||||
|
||||
# 最近告警
|
||||
recent_alarms_q = await db.execute(
|
||||
select(AlarmEvent).where(AlarmEvent.status == "active").order_by(AlarmEvent.triggered_at.desc()).limit(10)
|
||||
)
|
||||
recent_alarms = [
|
||||
{"id": a.id, "title": a.title, "severity": a.severity, "device_id": a.device_id,
|
||||
"triggered_at": str(a.triggered_at)}
|
||||
for a in recent_alarms_q.scalars().all()
|
||||
]
|
||||
|
||||
return {
|
||||
"device_stats": {
|
||||
"online": device_stats.get("online", 0),
|
||||
"offline": device_stats.get("offline", 0),
|
||||
"alarm": device_stats.get("alarm", 0),
|
||||
"total": sum(device_stats.values()),
|
||||
},
|
||||
"energy_today": energy_summary,
|
||||
"carbon": {
|
||||
"emission": carbon_row[0] or 0 if carbon_row else 0,
|
||||
"reduction": carbon_row[1] or 0 if carbon_row else 0,
|
||||
},
|
||||
"active_alarms": active_alarms,
|
||||
"recent_alarms": recent_alarms,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/realtime")
|
||||
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""实时功率数据 - 获取最近的采集数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
latest_q = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power")
|
||||
).order_by(EnergyData.timestamp.desc()).limit(50)
|
||||
)
|
||||
data_points = latest_q.scalars().all()
|
||||
|
||||
pv_ids = await _get_pv_device_ids(db)
|
||||
hp_ids = await _get_hp_device_ids(db)
|
||||
pv_power = sum(d.value for d in data_points if d.device_id in pv_ids)
|
||||
heatpump_power = sum(d.value for d in data_points if d.device_id in hp_ids)
|
||||
|
||||
return {
|
||||
"timestamp": str(now),
|
||||
"pv_power": round(pv_power, 2),
|
||||
"heatpump_power": round(heatpump_power, 2),
|
||||
"total_load": round(pv_power + heatpump_power, 2),
|
||||
"grid_power": round(max(0, heatpump_power - pv_power), 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/load-curve")
|
||||
async def get_load_curve(
|
||||
hours: int = 24,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""负荷曲线数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
start = now - timedelta(hours=hours)
|
||||
|
||||
settings = get_settings()
|
||||
if settings.is_sqlite:
|
||||
hour_expr = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('hour')
|
||||
else:
|
||||
hour_expr = func.date_trunc('hour', EnergyData.timestamp).label('hour')
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
hour_expr,
|
||||
func.avg(EnergyData.value).label('avg_power'),
|
||||
).where(
|
||||
and_(EnergyData.timestamp >= start, EnergyData.data_type == "power")
|
||||
).group_by(text('hour')).order_by(text('hour'))
|
||||
)
|
||||
return [{"time": str(row[0]), "power": round(row[1], 2)} for row in result.all()]
|
||||
|
||||
|
||||
async def _get_pv_device_ids(db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.device_type == "pv_inverter", Device.is_active == True)
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
|
||||
async def _get_hp_device_ids(db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.device_type == "heat_pump", Device.is_active == True)
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
206
backend/app/api/v1/devices.py
Normal file
206
backend/app/api/v1/devices.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.device import Device, DeviceType, DeviceGroup
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/devices", tags=["设备管理"])
|
||||
|
||||
|
||||
class DeviceCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
device_type: str
|
||||
group_id: int | None = None
|
||||
model: str | None = None
|
||||
manufacturer: str | None = None
|
||||
serial_number: str | None = None
|
||||
rated_power: float | None = None
|
||||
location: str | None = None
|
||||
protocol: str | None = None
|
||||
connection_params: dict | None = None
|
||||
collect_interval: int = 15
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
group_id: int | None = None
|
||||
location: str | None = None
|
||||
protocol: str | None = None
|
||||
connection_params: dict | None = None
|
||||
collect_interval: int | None = None
|
||||
status: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_devices(
|
||||
device_type: str | None = None,
|
||||
group_id: int | None = None,
|
||||
status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(Device).where(Device.is_active == True)
|
||||
if device_type:
|
||||
query = query.where(Device.device_type == device_type)
|
||||
if group_id:
|
||||
query = query.where(Device.group_id == group_id)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar()
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size).order_by(Device.id)
|
||||
result = await db.execute(query)
|
||||
devices = result.scalars().all()
|
||||
return {"total": total, "items": [_device_to_dict(d) for d in devices]}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
async def list_device_types(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(DeviceType).order_by(DeviceType.id))
|
||||
return [{"id": t.id, "code": t.code, "name": t.name, "icon": t.icon} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_device_groups(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id))
|
||||
return [{"id": g.id, "name": g.name, "parent_id": g.parent_id, "location": g.location} for g in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def device_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status)
|
||||
)
|
||||
stats = {row[0]: row[1] for row in result.all()}
|
||||
return {"online": stats.get("online", 0), "offline": stats.get("offline", 0), "alarm": stats.get("alarm", 0), "maintenance": stats.get("maintenance", 0)}
|
||||
|
||||
|
||||
@router.get("/{device_id}")
|
||||
async def get_device(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
device = Device(**data.model_dump())
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "device", detail=f"创建设备 {data.name} ({data.code})")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@router.put("/{device_id}")
|
||||
async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(device, k, v)
|
||||
await log_audit(db, user.id, "update", "device", detail=f"更新设备 {device.name}: {', '.join(updates.keys())}")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@router.get("/topology")
|
||||
async def get_device_topology(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""设备拓扑树 - Full device tree with counts and status"""
|
||||
# Get all groups
|
||||
group_result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id))
|
||||
groups = group_result.scalars().all()
|
||||
|
||||
# Get device counts and status per group
|
||||
status_query = (
|
||||
select(
|
||||
Device.group_id,
|
||||
Device.status,
|
||||
func.count(Device.id).label('cnt'),
|
||||
)
|
||||
.where(Device.is_active == True)
|
||||
.group_by(Device.group_id, Device.status)
|
||||
)
|
||||
status_result = await db.execute(status_query)
|
||||
status_rows = status_result.all()
|
||||
|
||||
# Build status map: group_id -> {status: count}
|
||||
group_stats: dict[int | None, dict[str, int]] = {}
|
||||
for group_id, status, cnt in status_rows:
|
||||
if group_id not in group_stats:
|
||||
group_stats[group_id] = {}
|
||||
group_stats[group_id][status] = cnt
|
||||
|
||||
# Build group nodes
|
||||
group_map: dict[int, dict] = {}
|
||||
for g in groups:
|
||||
stats = group_stats.get(g.id, {})
|
||||
device_count = sum(stats.values())
|
||||
group_map[g.id] = {
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"location": g.location,
|
||||
"parent_id": g.parent_id,
|
||||
"children": [],
|
||||
"device_count": device_count,
|
||||
"online_count": stats.get("online", 0),
|
||||
"offline_count": stats.get("offline", 0),
|
||||
"alarm_count": stats.get("alarm", 0),
|
||||
}
|
||||
|
||||
# Build tree
|
||||
roots = []
|
||||
for gid, node in group_map.items():
|
||||
pid = node["parent_id"]
|
||||
if pid and pid in group_map:
|
||||
group_map[pid]["children"].append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
|
||||
# Propagate child counts up
|
||||
def propagate(node: dict) -> tuple[int, int, int, int]:
|
||||
total = node["device_count"]
|
||||
online = node["online_count"]
|
||||
offline = node["offline_count"]
|
||||
alarm = node["alarm_count"]
|
||||
for child in node["children"]:
|
||||
ct, co, coff, ca = propagate(child)
|
||||
total += ct
|
||||
online += co
|
||||
offline += coff
|
||||
alarm += ca
|
||||
node["total_device_count"] = total
|
||||
node["total_online"] = online
|
||||
node["total_offline"] = offline
|
||||
node["total_alarm"] = alarm
|
||||
return total, online, offline, alarm
|
||||
|
||||
for root in roots:
|
||||
propagate(root)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
def _device_to_dict(d: Device) -> dict:
|
||||
return {
|
||||
"id": d.id, "name": d.name, "code": d.code, "device_type": d.device_type,
|
||||
"group_id": d.group_id, "model": d.model, "manufacturer": d.manufacturer,
|
||||
"serial_number": d.serial_number, "rated_power": d.rated_power,
|
||||
"location": d.location, "protocol": d.protocol, "collect_interval": d.collect_interval,
|
||||
"status": d.status, "is_active": d.is_active, "last_data_time": str(d.last_data_time) if d.last_data_time else None,
|
||||
}
|
||||
763
backend/app/api/v1/energy.py
Normal file
763
backend/app/api/v1/energy.py
Normal file
@@ -0,0 +1,763 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text, Integer
|
||||
from sqlalchemy.orm import joinedload
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.energy import EnergyData, EnergyDailySummary, EnergyCategory
|
||||
from app.models.device import Device
|
||||
from app.models.user import User
|
||||
from app.core.deps import require_roles
|
||||
|
||||
router = APIRouter(prefix="/energy", tags=["能耗数据"])
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def query_history(
|
||||
device_id: int | None = None,
|
||||
data_type: str = "power",
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
granularity: str = Query("hour", pattern="^(raw|5min|hour|day)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""历史数据查询"""
|
||||
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
query = query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(EnergyData.timestamp <= end_time)
|
||||
|
||||
if granularity == "raw":
|
||||
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return [{"timestamp": str(d.timestamp), "value": d.value, "unit": d.unit, "device_id": d.device_id}
|
||||
for d in result.scalars().all()]
|
||||
else:
|
||||
settings = get_settings()
|
||||
if granularity == "5min":
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d %H:', EnergyData.timestamp).op('||')(
|
||||
func.printf('%02d:00', (func.cast(func.strftime('%M', EnergyData.timestamp), Integer) / 5) * 5)
|
||||
).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.to_timestamp(
|
||||
func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300
|
||||
).label('time_bucket')
|
||||
elif granularity == "hour":
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket')
|
||||
else: # day
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d', EnergyData.timestamp).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket')
|
||||
agg_query = select(
|
||||
time_bucket,
|
||||
func.avg(EnergyData.value).label('avg_value'),
|
||||
func.max(EnergyData.value).label('max_value'),
|
||||
func.min(EnergyData.value).label('min_value'),
|
||||
).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_time)
|
||||
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
||||
result = await db.execute(agg_query)
|
||||
return [{"time": str(r[0]), "avg": round(r[1], 2), "max": round(r[2], 2), "min": round(r[3], 2)}
|
||||
for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/params")
|
||||
async def query_electrical_params(
|
||||
device_id: int = Query(..., description="设备ID"),
|
||||
params: str = Query("power", description="参数列表(逗号分隔): power,voltage,current,power_factor,temperature,frequency,cop"),
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
granularity: str = Query("raw", pattern="^(raw|5min|hour|day)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""电参量查询 - Query multiple electrical parameters for a device"""
|
||||
param_list = [p.strip() for p in params.split(",") if p.strip()]
|
||||
result_data = {}
|
||||
settings = get_settings()
|
||||
|
||||
for param in param_list:
|
||||
base_filter = and_(
|
||||
EnergyData.device_id == device_id,
|
||||
EnergyData.data_type == param,
|
||||
)
|
||||
conditions = [base_filter]
|
||||
if start_time:
|
||||
conditions.append(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(EnergyData.timestamp <= end_time)
|
||||
combined = and_(*conditions)
|
||||
|
||||
if granularity == "raw":
|
||||
query = (
|
||||
select(EnergyData.timestamp, EnergyData.value, EnergyData.unit)
|
||||
.where(combined)
|
||||
.order_by(EnergyData.timestamp)
|
||||
.limit(5000)
|
||||
)
|
||||
rows = await db.execute(query)
|
||||
result_data[param] = [
|
||||
{"timestamp": str(r[0]), "value": r[1], "unit": r[2]}
|
||||
for r in rows.all()
|
||||
]
|
||||
else:
|
||||
if granularity == "5min":
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d %H:', EnergyData.timestamp).op('||')(
|
||||
func.printf('%02d:00', (func.cast(func.strftime('%M', EnergyData.timestamp), Integer) / 5) * 5)
|
||||
).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.to_timestamp(
|
||||
func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300
|
||||
).label('time_bucket')
|
||||
elif granularity == "hour":
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket')
|
||||
else: # day
|
||||
if settings.is_sqlite:
|
||||
time_bucket = func.strftime('%Y-%m-%d', EnergyData.timestamp).label('time_bucket')
|
||||
else:
|
||||
time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket')
|
||||
|
||||
agg_query = (
|
||||
select(time_bucket, func.avg(EnergyData.value).label('avg_value'))
|
||||
.where(combined)
|
||||
.group_by(text('time_bucket'))
|
||||
.order_by(text('time_bucket'))
|
||||
)
|
||||
rows = await db.execute(agg_query)
|
||||
result_data[param] = [
|
||||
{"timestamp": str(r[0]), "value": round(r[1], 2)}
|
||||
for r in rows.all()
|
||||
]
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
@router.get("/daily-summary")
|
||||
async def daily_summary(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
energy_type: str | None = None,
|
||||
device_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""每日能耗汇总"""
|
||||
query = select(EnergyDailySummary)
|
||||
if start_date:
|
||||
query = query.where(EnergyDailySummary.date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(EnergyDailySummary.date <= end_date)
|
||||
if energy_type:
|
||||
query = query.where(EnergyDailySummary.energy_type == energy_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyDailySummary.device_id == device_id)
|
||||
query = query.order_by(EnergyDailySummary.date.desc()).limit(365)
|
||||
result = await db.execute(query)
|
||||
return [{
|
||||
"date": str(s.date), "device_id": s.device_id, "energy_type": s.energy_type,
|
||||
"consumption": s.total_consumption, "generation": s.total_generation,
|
||||
"peak_power": s.peak_power, "avg_power": s.avg_power,
|
||||
"operating_hours": s.operating_hours, "cost": s.cost, "carbon_emission": s.carbon_emission,
|
||||
} for s in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
async def energy_comparison(
|
||||
device_id: int | None = None,
|
||||
energy_type: str = "electricity",
|
||||
period: str = Query("month", pattern="^(day|week|month|year)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""能耗同比环比分析"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if period == "day":
|
||||
current_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(days=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "week":
|
||||
current_start = now - timedelta(days=now.weekday())
|
||||
current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(weeks=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "month":
|
||||
current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = (current_start - timedelta(days=1)).replace(day=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
else:
|
||||
current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start.replace(year=current_start.year - 1)
|
||||
yoy_start = prev_start
|
||||
|
||||
async def sum_consumption(start, end):
|
||||
q = select(func.sum(EnergyDailySummary.total_consumption)).where(
|
||||
and_(EnergyDailySummary.date >= start, EnergyDailySummary.date < end,
|
||||
EnergyDailySummary.energy_type == energy_type)
|
||||
)
|
||||
if device_id:
|
||||
q = q.where(EnergyDailySummary.device_id == device_id)
|
||||
r = await db.execute(q)
|
||||
return r.scalar() or 0
|
||||
|
||||
current = await sum_consumption(current_start, now)
|
||||
previous = await sum_consumption(prev_start, current_start)
|
||||
yoy = await sum_consumption(yoy_start, yoy_start.replace(year=yoy_start.year + 1))
|
||||
|
||||
return {
|
||||
"current": round(current, 2),
|
||||
"previous": round(previous, 2),
|
||||
"yoy": round(yoy, 2),
|
||||
"mom_change": round((current - previous) / previous * 100, 1) if previous else 0,
|
||||
"yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_energy_data(
|
||||
start_time: str = Query(..., description="开始时间, e.g. 2026-03-01"),
|
||||
end_time: str = Query(..., description="结束时间, e.g. 2026-03-31"),
|
||||
device_id: int | None = Query(None, description="设备ID (可选)"),
|
||||
data_type: str | None = Query(None, description="数据类型 (可选, e.g. power, energy)"),
|
||||
format: str = Query("csv", pattern="^(csv|xlsx)$", description="导出格式: csv 或 xlsx"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出能耗数据为CSV或Excel文件"""
|
||||
# Parse date strings to datetime for proper PostgreSQL comparison
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
except ValueError:
|
||||
start_dt = datetime.strptime(start_time, "%Y-%m-%d")
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
except ValueError:
|
||||
end_dt = datetime.strptime(end_time, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
|
||||
# If end_time was just a date (no time component), set to end of day
|
||||
if end_dt.hour == 0 and end_dt.minute == 0 and end_dt.second == 0 and "T" not in end_time:
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
|
||||
# Query energy data with device names
|
||||
query = (
|
||||
select(EnergyData, Device.name.label("device_name"))
|
||||
.join(Device, EnergyData.device_id == Device.id, isouter=True)
|
||||
.where(
|
||||
and_(
|
||||
EnergyData.timestamp >= start_dt,
|
||||
EnergyData.timestamp <= end_dt,
|
||||
)
|
||||
)
|
||||
)
|
||||
if device_id:
|
||||
query = query.where(EnergyData.device_id == device_id)
|
||||
if data_type:
|
||||
query = query.where(EnergyData.data_type == data_type)
|
||||
query = query.order_by(EnergyData.timestamp)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
headers = ["timestamp", "device_name", "data_type", "value", "unit"]
|
||||
data_rows = []
|
||||
for row in rows:
|
||||
energy = row[0] # EnergyData object
|
||||
device_name = row[1] or f"Device#{energy.device_id}"
|
||||
data_rows.append([
|
||||
str(energy.timestamp) if energy.timestamp else "",
|
||||
device_name,
|
||||
energy.data_type or "",
|
||||
energy.value,
|
||||
energy.unit or "",
|
||||
])
|
||||
|
||||
date_suffix = f"{start_time}_{end_time}".replace("-", "")
|
||||
if format == "xlsx":
|
||||
return _export_xlsx(headers, data_rows, f"energy_export_{date_suffix}.xlsx")
|
||||
else:
|
||||
return _export_csv(headers, data_rows, f"energy_export_{date_suffix}.csv")
|
||||
|
||||
|
||||
def _export_csv(headers: list[str], rows: list[list], filename: str) -> StreamingResponse:
|
||||
"""Generate CSV streaming response."""
|
||||
output = io.StringIO()
|
||||
# Add BOM for Excel compatibility with Chinese characters
|
||||
output.write('\ufeff')
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
writer.writerows(rows)
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _export_xlsx(headers: list[str], rows: list[list], filename: str) -> StreamingResponse:
|
||||
"""Generate XLSX streaming response."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "能耗数据"
|
||||
|
||||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
header_fill = PatternFill(start_color="336699", end_color="336699", fill_type="solid")
|
||||
header_align = Alignment(horizontal="center", vertical="center")
|
||||
thin_border = Border(
|
||||
left=Side(style="thin", color="CCCCCC"),
|
||||
right=Side(style="thin", color="CCCCCC"),
|
||||
top=Side(style="thin", color="CCCCCC"),
|
||||
bottom=Side(style="thin", color="CCCCCC"),
|
||||
)
|
||||
|
||||
# Write headers
|
||||
for col_idx, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_align
|
||||
cell.border = thin_border
|
||||
|
||||
# Write data
|
||||
for row_idx, row_data in enumerate(rows, 2):
|
||||
for col_idx, val in enumerate(row_data, 1):
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=val)
|
||||
cell.border = thin_border
|
||||
if isinstance(val, float):
|
||||
cell.number_format = "#,##0.00"
|
||||
|
||||
# Auto-width
|
||||
for col_idx in range(1, len(headers) + 1):
|
||||
max_len = len(str(headers[col_idx - 1]))
|
||||
for row_idx in range(2, min(len(rows) + 2, 102)):
|
||||
val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if val:
|
||||
max_len = max(max_len, len(str(val)))
|
||||
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = min(max_len + 4, 40)
|
||||
|
||||
ws.freeze_panes = "A2"
|
||||
if rows:
|
||||
ws.auto_filter.ref = f"A1:{ws.cell(row=1, column=len(headers)).column_letter}{len(rows) + 1}"
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Energy Category (分项能耗) ──────────────────────────────────────
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
parent_id: int | None = None
|
||||
sort_order: int = 0
|
||||
icon: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
def _category_to_dict(c: EnergyCategory) -> dict:
|
||||
return {
|
||||
"id": c.id, "name": c.name, "code": c.code,
|
||||
"parent_id": c.parent_id, "sort_order": c.sort_order,
|
||||
"icon": c.icon, "color": c.color,
|
||||
"created_at": str(c.created_at) if c.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_category_tree(items: list[dict], parent_id: int | None = None) -> list[dict]:
|
||||
tree = []
|
||||
for item in items:
|
||||
if item["parent_id"] == parent_id:
|
||||
children = _build_category_tree(items, item["id"])
|
||||
if children:
|
||||
item["children"] = children
|
||||
tree.append(item)
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取能耗分项类别(树结构)"""
|
||||
result = await db.execute(
|
||||
select(EnergyCategory).order_by(EnergyCategory.sort_order, EnergyCategory.id)
|
||||
)
|
||||
items = [_category_to_dict(c) for c in result.scalars().all()]
|
||||
return _build_category_tree(items)
|
||||
|
||||
|
||||
@router.post("/categories")
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""创建能耗分项类别"""
|
||||
cat = EnergyCategory(**data.model_dump())
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
return _category_to_dict(cat)
|
||||
|
||||
|
||||
@router.put("/categories/{cat_id}")
|
||||
async def update_category(
|
||||
cat_id: int,
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""更新能耗分项类别"""
|
||||
result = await db.execute(select(EnergyCategory).where(EnergyCategory.id == cat_id))
|
||||
cat = result.scalar_one_or_none()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="分项类别不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cat, k, v)
|
||||
return _category_to_dict(cat)
|
||||
|
||||
|
||||
@router.get("/by-category")
|
||||
async def energy_by_category(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
energy_type: str = "electricity",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""按分项类别统计能耗"""
|
||||
query = (
|
||||
select(
|
||||
EnergyCategory.id,
|
||||
EnergyCategory.name,
|
||||
EnergyCategory.code,
|
||||
EnergyCategory.color,
|
||||
func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"),
|
||||
)
|
||||
.select_from(EnergyCategory)
|
||||
.outerjoin(Device, Device.category_id == EnergyCategory.id)
|
||||
.outerjoin(
|
||||
EnergyDailySummary,
|
||||
and_(
|
||||
EnergyDailySummary.device_id == Device.id,
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
),
|
||||
)
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(EnergyDailySummary.date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(EnergyDailySummary.date <= end_date)
|
||||
query = query.group_by(EnergyCategory.id, EnergyCategory.name, EnergyCategory.code, EnergyCategory.color)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
total = sum(r.consumption for r in rows) or 1
|
||||
return [
|
||||
{
|
||||
"id": r.id, "name": r.name, "code": r.code, "color": r.color,
|
||||
"consumption": round(r.consumption, 2),
|
||||
"percentage": round(r.consumption / total * 100, 1),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/category-ranking")
|
||||
async def category_ranking(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
energy_type: str = "electricity",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""分项能耗排名"""
|
||||
query = (
|
||||
select(
|
||||
EnergyCategory.name,
|
||||
EnergyCategory.color,
|
||||
func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"),
|
||||
)
|
||||
.select_from(EnergyCategory)
|
||||
.outerjoin(Device, Device.category_id == EnergyCategory.id)
|
||||
.outerjoin(
|
||||
EnergyDailySummary,
|
||||
and_(
|
||||
EnergyDailySummary.device_id == Device.id,
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
),
|
||||
)
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(EnergyDailySummary.date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(EnergyDailySummary.date <= end_date)
|
||||
query = query.group_by(EnergyCategory.name, EnergyCategory.color).order_by(text("consumption DESC"))
|
||||
result = await db.execute(query)
|
||||
return [{"name": r.name, "color": r.color, "consumption": round(r.consumption, 2)} for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/category-trend")
|
||||
async def category_trend(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
energy_type: str = "electricity",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""分项能耗每日趋势"""
|
||||
query = (
|
||||
select(
|
||||
EnergyDailySummary.date,
|
||||
EnergyCategory.name,
|
||||
EnergyCategory.color,
|
||||
func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"),
|
||||
)
|
||||
.select_from(EnergyDailySummary)
|
||||
.join(Device, EnergyDailySummary.device_id == Device.id)
|
||||
.join(EnergyCategory, Device.category_id == EnergyCategory.id)
|
||||
.where(EnergyDailySummary.energy_type == energy_type)
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(EnergyDailySummary.date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(EnergyDailySummary.date <= end_date)
|
||||
query = query.group_by(EnergyDailySummary.date, EnergyCategory.name, EnergyCategory.color)
|
||||
query = query.order_by(EnergyDailySummary.date)
|
||||
result = await db.execute(query)
|
||||
return [
|
||||
{"date": str(r.date), "category": r.name, "color": r.color, "consumption": round(r.consumption, 2)}
|
||||
for r in result.all()
|
||||
]
|
||||
|
||||
|
||||
# ── Loss / YoY / MoM Analysis ─────────────────────────────────────
|
||||
|
||||
from app.models.device import DeviceGroup
|
||||
|
||||
|
||||
@router.get("/loss")
|
||||
async def get_energy_loss(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
energy_type: str = "electricity",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""能耗损耗分析 - Compare parent meter vs sum of sub-meters"""
|
||||
# Get all groups that have children
|
||||
groups_result = await db.execute(select(DeviceGroup))
|
||||
all_groups = groups_result.scalars().all()
|
||||
group_map = {g.id: g for g in all_groups}
|
||||
parent_ids = {g.parent_id for g in all_groups if g.parent_id is not None}
|
||||
|
||||
results = []
|
||||
for gid in parent_ids:
|
||||
group = group_map.get(gid)
|
||||
if not group:
|
||||
continue
|
||||
child_group_ids = [g.id for g in all_groups if g.parent_id == gid]
|
||||
|
||||
# Parent consumption: devices directly in this group
|
||||
parent_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from(
|
||||
EnergyDailySummary
|
||||
).join(Device, EnergyDailySummary.device_id == Device.id).where(
|
||||
and_(
|
||||
Device.group_id == gid,
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
EnergyDailySummary.date >= start_date,
|
||||
EnergyDailySummary.date <= end_date,
|
||||
)
|
||||
)
|
||||
parent_consumption = (await db.execute(parent_q)).scalar() or 0
|
||||
|
||||
# Children consumption: devices in child groups
|
||||
if child_group_ids:
|
||||
children_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from(
|
||||
EnergyDailySummary
|
||||
).join(Device, EnergyDailySummary.device_id == Device.id).where(
|
||||
and_(
|
||||
Device.group_id.in_(child_group_ids),
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
EnergyDailySummary.date >= start_date,
|
||||
EnergyDailySummary.date <= end_date,
|
||||
)
|
||||
)
|
||||
children_consumption = (await db.execute(children_q)).scalar() or 0
|
||||
else:
|
||||
children_consumption = 0
|
||||
|
||||
loss = parent_consumption - children_consumption
|
||||
loss_rate = (loss / parent_consumption * 100) if parent_consumption > 0 else 0
|
||||
|
||||
results.append({
|
||||
"group_name": group.name,
|
||||
"parent_consumption": round(parent_consumption, 2),
|
||||
"children_consumption": round(children_consumption, 2),
|
||||
"loss": round(loss, 2),
|
||||
"loss_rate_pct": round(loss_rate, 1),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/yoy")
|
||||
async def get_yoy_comparison(
|
||||
year: int | None = None,
|
||||
energy_type: str = "electricity",
|
||||
group_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""同比分析 - Current year vs previous year, month by month"""
|
||||
current_year = year or datetime.now(timezone.utc).year
|
||||
prev_year = current_year - 1
|
||||
settings = get_settings()
|
||||
|
||||
results = []
|
||||
for month in range(1, 13):
|
||||
for yr, label in [(current_year, "current_year"), (prev_year, "previous_year")]:
|
||||
month_start = f"{yr}-{month:02d}-01"
|
||||
if month == 12:
|
||||
month_end = f"{yr + 1}-01-01"
|
||||
else:
|
||||
month_end = f"{yr}-{month + 1:02d}-01"
|
||||
|
||||
q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where(
|
||||
and_(
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
EnergyDailySummary.date >= month_start,
|
||||
EnergyDailySummary.date < month_end,
|
||||
)
|
||||
)
|
||||
if group_id:
|
||||
q = q.select_from(EnergyDailySummary).join(
|
||||
Device, EnergyDailySummary.device_id == Device.id
|
||||
).where(Device.group_id == group_id)
|
||||
|
||||
val = (await db.execute(q)).scalar() or 0
|
||||
# Find or create month entry
|
||||
existing = next((r for r in results if r["month"] == month), None)
|
||||
if not existing:
|
||||
existing = {"month": month, "current_year": 0, "previous_year": 0, "change_pct": 0}
|
||||
results.append(existing)
|
||||
existing[label] = round(val, 2)
|
||||
|
||||
# Calculate change percentages
|
||||
for r in results:
|
||||
if r["previous_year"] > 0:
|
||||
r["change_pct"] = round((r["current_year"] - r["previous_year"]) / r["previous_year"] * 100, 1)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/mom")
|
||||
async def get_mom_comparison(
|
||||
period: str = "month",
|
||||
energy_type: str = "electricity",
|
||||
group_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""环比分析 - Current period vs previous period"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if period == "month":
|
||||
current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = (current_start - timedelta(days=1)).replace(day=1)
|
||||
# Generate daily labels
|
||||
days_in_month = (now - current_start).days + 1
|
||||
prev_end = current_start
|
||||
labels = [f"{i + 1}日" for i in range(31)]
|
||||
elif period == "week":
|
||||
current_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(weeks=1)
|
||||
prev_end = current_start
|
||||
labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
else: # day
|
||||
current_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(days=1)
|
||||
prev_end = current_start
|
||||
labels = [f"{i}:00" for i in range(24)]
|
||||
|
||||
async def get_period_data(start, end):
|
||||
q = select(
|
||||
EnergyDailySummary.date,
|
||||
func.sum(EnergyDailySummary.total_consumption).label("consumption"),
|
||||
).where(
|
||||
and_(
|
||||
EnergyDailySummary.energy_type == energy_type,
|
||||
EnergyDailySummary.date >= str(start)[:10],
|
||||
EnergyDailySummary.date < str(end)[:10],
|
||||
)
|
||||
)
|
||||
if group_id:
|
||||
q = q.select_from(EnergyDailySummary).join(
|
||||
Device, EnergyDailySummary.device_id == Device.id
|
||||
).where(Device.group_id == group_id)
|
||||
q = q.group_by(EnergyDailySummary.date).order_by(EnergyDailySummary.date)
|
||||
result = await db.execute(q)
|
||||
return [{"date": str(r.date), "consumption": round(r.consumption, 2)} for r in result.all()]
|
||||
|
||||
current_data = await get_period_data(current_start, now)
|
||||
previous_data = await get_period_data(prev_start, prev_end)
|
||||
|
||||
# Build comparison items
|
||||
max_len = max(len(current_data), len(previous_data), 1)
|
||||
items = []
|
||||
for i in range(max_len):
|
||||
cur_val = current_data[i]["consumption"] if i < len(current_data) else 0
|
||||
prev_val = previous_data[i]["consumption"] if i < len(previous_data) else 0
|
||||
change_pct = round((cur_val - prev_val) / prev_val * 100, 1) if prev_val > 0 else 0
|
||||
items.append({
|
||||
"label": labels[i] if i < len(labels) else str(i + 1),
|
||||
"current_period": cur_val,
|
||||
"previous_period": prev_val,
|
||||
"change_pct": change_pct,
|
||||
})
|
||||
|
||||
total_current = sum(d["consumption"] for d in current_data)
|
||||
total_previous = sum(d["consumption"] for d in previous_data)
|
||||
total_change = round((total_current - total_previous) / total_previous * 100, 1) if total_previous > 0 else 0
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total_current": round(total_current, 2),
|
||||
"total_previous": round(total_previous, 2),
|
||||
"total_change_pct": total_change,
|
||||
}
|
||||
376
backend/app/api/v1/energy_strategy.py
Normal file
376
backend/app/api/v1/energy_strategy.py
Normal file
@@ -0,0 +1,376 @@
|
||||
from datetime import date, datetime
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.energy_strategy import (
|
||||
TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.services.energy_strategy import (
|
||||
get_active_tou_pricing, get_tou_periods,
|
||||
calculate_monthly_cost_breakdown, get_recommendations,
|
||||
get_savings_report, simulate_strategy_impact, DEFAULT_PERIODS, PERIOD_LABELS,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/strategy", tags=["策略优化"])
|
||||
|
||||
|
||||
# ---- Schemas ----
|
||||
|
||||
class TouPricingCreate(BaseModel):
|
||||
name: str
|
||||
region: str = "北京"
|
||||
effective_date: str | None = None
|
||||
end_date: str | None = None
|
||||
|
||||
|
||||
class TouPricingPeriodCreate(BaseModel):
|
||||
period_type: str # sharp_peak, peak, flat, valley
|
||||
start_time: str # HH:MM
|
||||
end_time: str # HH:MM
|
||||
price_yuan_per_kwh: float
|
||||
month_range: str | None = None
|
||||
|
||||
|
||||
class TouPricingPeriodsSet(BaseModel):
|
||||
periods: list[TouPricingPeriodCreate]
|
||||
|
||||
|
||||
class EnergyStrategyCreate(BaseModel):
|
||||
name: str
|
||||
strategy_type: str # heat_storage, load_shift, pv_priority
|
||||
description: str | None = None
|
||||
parameters: dict | None = None
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class EnergyStrategyUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
parameters: dict | None = None
|
||||
is_enabled: bool | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class SimulateRequest(BaseModel):
|
||||
daily_consumption_kwh: float = 2000
|
||||
pv_daily_kwh: float = 800
|
||||
strategies: list[str] = ["heat_storage", "pv_priority", "load_shift"]
|
||||
|
||||
|
||||
# ---- TOU Pricing ----
|
||||
|
||||
@router.get("/pricing")
|
||||
async def list_tou_pricing(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取分时电价配置列表"""
|
||||
result = await db.execute(
|
||||
select(TouPricing).order_by(TouPricing.created_at.desc())
|
||||
)
|
||||
pricings = result.scalars().all()
|
||||
items = []
|
||||
for p in pricings:
|
||||
periods = await get_tou_periods(db, p.id)
|
||||
items.append({
|
||||
"id": p.id, "name": p.name, "region": p.region,
|
||||
"effective_date": str(p.effective_date) if p.effective_date else None,
|
||||
"end_date": str(p.end_date) if p.end_date else None,
|
||||
"is_active": p.is_active,
|
||||
"created_at": str(p.created_at),
|
||||
"periods": [
|
||||
{
|
||||
"id": pp.id,
|
||||
"period_type": pp.period_type,
|
||||
"period_label": PERIOD_LABELS.get(pp.period_type, pp.period_type),
|
||||
"start_time": pp.start_time,
|
||||
"end_time": pp.end_time,
|
||||
"price_yuan_per_kwh": pp.price_yuan_per_kwh,
|
||||
"month_range": pp.month_range,
|
||||
}
|
||||
for pp in periods
|
||||
],
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/pricing")
|
||||
async def create_tou_pricing(
|
||||
data: TouPricingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建分时电价配置"""
|
||||
pricing = TouPricing(
|
||||
name=data.name,
|
||||
region=data.region,
|
||||
effective_date=date.fromisoformat(data.effective_date) if data.effective_date else None,
|
||||
end_date=date.fromisoformat(data.end_date) if data.end_date else None,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(pricing)
|
||||
await db.flush()
|
||||
return {"id": pricing.id, "message": "分时电价配置创建成功"}
|
||||
|
||||
|
||||
@router.put("/pricing/{pricing_id}")
|
||||
async def update_tou_pricing(
|
||||
pricing_id: int,
|
||||
data: TouPricingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新分时电价配置"""
|
||||
result = await db.execute(select(TouPricing).where(TouPricing.id == pricing_id))
|
||||
pricing = result.scalar_one_or_none()
|
||||
if not pricing:
|
||||
raise HTTPException(status_code=404, detail="电价配置不存在")
|
||||
pricing.name = data.name
|
||||
pricing.region = data.region
|
||||
pricing.effective_date = date.fromisoformat(data.effective_date) if data.effective_date else None
|
||||
pricing.end_date = date.fromisoformat(data.end_date) if data.end_date else None
|
||||
return {"message": "电价配置更新成功"}
|
||||
|
||||
|
||||
@router.get("/pricing/{pricing_id}/periods")
|
||||
async def get_pricing_periods(
|
||||
pricing_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取电价时段"""
|
||||
periods = await get_tou_periods(db, pricing_id)
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"period_type": p.period_type,
|
||||
"period_label": PERIOD_LABELS.get(p.period_type, p.period_type),
|
||||
"start_time": p.start_time,
|
||||
"end_time": p.end_time,
|
||||
"price_yuan_per_kwh": p.price_yuan_per_kwh,
|
||||
"month_range": p.month_range,
|
||||
}
|
||||
for p in periods
|
||||
]
|
||||
|
||||
|
||||
@router.post("/pricing/{pricing_id}/periods")
|
||||
async def set_pricing_periods(
|
||||
pricing_id: int,
|
||||
data: TouPricingPeriodsSet,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""设置电价时段(替换所有现有时段)"""
|
||||
result = await db.execute(select(TouPricing).where(TouPricing.id == pricing_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="电价配置不存在")
|
||||
|
||||
# Delete existing periods
|
||||
existing = await db.execute(
|
||||
select(TouPricingPeriod).where(TouPricingPeriod.pricing_id == pricing_id)
|
||||
)
|
||||
for p in existing.scalars().all():
|
||||
await db.delete(p)
|
||||
|
||||
# Create new periods
|
||||
for period in data.periods:
|
||||
pp = TouPricingPeriod(
|
||||
pricing_id=pricing_id,
|
||||
period_type=period.period_type,
|
||||
start_time=period.start_time,
|
||||
end_time=period.end_time,
|
||||
price_yuan_per_kwh=period.price_yuan_per_kwh,
|
||||
month_range=period.month_range,
|
||||
)
|
||||
db.add(pp)
|
||||
|
||||
return {"message": f"已设置{len(data.periods)}个时段"}
|
||||
|
||||
|
||||
# ---- Strategies ----
|
||||
|
||||
@router.get("/strategies")
|
||||
async def list_strategies(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取优化策略列表"""
|
||||
result = await db.execute(
|
||||
select(EnergyStrategy).order_by(EnergyStrategy.priority.desc())
|
||||
)
|
||||
strategies = result.scalars().all()
|
||||
|
||||
if not strategies:
|
||||
# Return defaults
|
||||
return [
|
||||
{
|
||||
"id": None, "name": "谷电蓄热", "strategy_type": "heat_storage",
|
||||
"description": "在低谷电价时段(23:00-7:00)预热水箱,减少尖峰时段热泵运行",
|
||||
"is_enabled": False, "priority": 3,
|
||||
"parameters": {"shift_ratio": 0.3, "valley_start": "23:00", "valley_end": "07:00"},
|
||||
},
|
||||
{
|
||||
"id": None, "name": "光伏自消纳优先", "strategy_type": "pv_priority",
|
||||
"description": "优先使用光伏发电供给园区负荷,减少向电网购电",
|
||||
"is_enabled": True, "priority": 2,
|
||||
"parameters": {"min_self_consumption_ratio": 0.7},
|
||||
},
|
||||
{
|
||||
"id": None, "name": "负荷转移", "strategy_type": "load_shift",
|
||||
"description": "将可调负荷从尖峰时段转移至平段或低谷时段",
|
||||
"is_enabled": False, "priority": 1,
|
||||
"parameters": {"max_shift_ratio": 0.15, "target_periods": ["flat", "valley"]},
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id, "name": s.name, "strategy_type": s.strategy_type,
|
||||
"description": s.description, "is_enabled": s.is_enabled,
|
||||
"priority": s.priority, "parameters": s.parameters or {},
|
||||
}
|
||||
for s in strategies
|
||||
]
|
||||
|
||||
|
||||
@router.post("/strategies")
|
||||
async def create_strategy(
|
||||
data: EnergyStrategyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建优化策略"""
|
||||
strategy = EnergyStrategy(
|
||||
name=data.name,
|
||||
strategy_type=data.strategy_type,
|
||||
description=data.description,
|
||||
parameters=data.parameters or {},
|
||||
priority=data.priority,
|
||||
)
|
||||
db.add(strategy)
|
||||
await db.flush()
|
||||
return {"id": strategy.id, "message": "策略创建成功"}
|
||||
|
||||
|
||||
@router.put("/strategies/{strategy_id}")
|
||||
async def update_strategy(
|
||||
strategy_id: int,
|
||||
data: EnergyStrategyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新优化策略"""
|
||||
result = await db.execute(select(EnergyStrategy).where(EnergyStrategy.id == strategy_id))
|
||||
strategy = result.scalar_one_or_none()
|
||||
if not strategy:
|
||||
raise HTTPException(status_code=404, detail="策略不存在")
|
||||
|
||||
if data.name is not None:
|
||||
strategy.name = data.name
|
||||
if data.description is not None:
|
||||
strategy.description = data.description
|
||||
if data.parameters is not None:
|
||||
strategy.parameters = data.parameters
|
||||
if data.is_enabled is not None:
|
||||
strategy.is_enabled = data.is_enabled
|
||||
if data.priority is not None:
|
||||
strategy.priority = data.priority
|
||||
|
||||
return {"message": "策略更新成功"}
|
||||
|
||||
|
||||
@router.put("/strategies/{strategy_id}/toggle")
|
||||
async def toggle_strategy(
|
||||
strategy_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""启用/停用策略"""
|
||||
result = await db.execute(select(EnergyStrategy).where(EnergyStrategy.id == strategy_id))
|
||||
strategy = result.scalar_one_or_none()
|
||||
if not strategy:
|
||||
raise HTTPException(status_code=404, detail="策略不存在")
|
||||
strategy.is_enabled = not strategy.is_enabled
|
||||
return {"is_enabled": strategy.is_enabled, "message": f"策略已{'启用' if strategy.is_enabled else '停用'}"}
|
||||
|
||||
|
||||
# ---- Analysis ----
|
||||
|
||||
@router.get("/cost-analysis")
|
||||
async def cost_analysis(
|
||||
year: int = Query(default=2026),
|
||||
month: int = Query(default=4, ge=1, le=12),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""月度费用分析"""
|
||||
return await calculate_monthly_cost_breakdown(db, year, month)
|
||||
|
||||
|
||||
@router.get("/savings-report")
|
||||
async def savings_report(
|
||||
year: int = Query(default=2026),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""节约报告"""
|
||||
return await get_savings_report(db, year)
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def strategy_recommendations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前推荐策略"""
|
||||
return await get_recommendations(db)
|
||||
|
||||
|
||||
@router.post("/simulate")
|
||||
async def simulate(
|
||||
data: SimulateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""模拟策略影响"""
|
||||
pricing = await get_active_tou_pricing(db)
|
||||
if pricing:
|
||||
periods = await get_tou_periods(db, pricing.id)
|
||||
else:
|
||||
# Use default periods
|
||||
periods = [
|
||||
TouPricingPeriod(
|
||||
period_type=p["period_type"],
|
||||
start_time=p["start_time"],
|
||||
end_time=p["end_time"],
|
||||
price_yuan_per_kwh=p["price"],
|
||||
)
|
||||
for p in DEFAULT_PERIODS
|
||||
]
|
||||
|
||||
return simulate_strategy_impact(
|
||||
daily_consumption_kwh=data.daily_consumption_kwh,
|
||||
pv_daily_kwh=data.pv_daily_kwh,
|
||||
periods=periods,
|
||||
strategies=data.strategies,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/default-pricing")
|
||||
async def get_default_pricing(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取北京工业默认电价"""
|
||||
return {
|
||||
"region": "北京",
|
||||
"type": "工业用电",
|
||||
"periods": [
|
||||
{**p, "period_label": PERIOD_LABELS.get(p["period_type"], p["period_type"])}
|
||||
for p in DEFAULT_PERIODS
|
||||
],
|
||||
}
|
||||
489
backend/app/api/v1/maintenance.py
Normal file
489
backend/app/api/v1/maintenance.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.maintenance import InspectionPlan, InspectionRecord, RepairOrder, DutySchedule
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/maintenance", tags=["运维管理"])
|
||||
|
||||
|
||||
# ── Pydantic Schemas ────────────────────────────────────────────────
|
||||
|
||||
class PlanCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
device_group_id: int | None = None
|
||||
device_ids: list[int] | None = None
|
||||
schedule_type: str | None = None
|
||||
schedule_cron: str | None = None
|
||||
inspector_id: int | None = None
|
||||
checklist: list[dict] | None = None
|
||||
is_active: bool = True
|
||||
next_run_at: str | None = None
|
||||
|
||||
|
||||
class RecordCreate(BaseModel):
|
||||
plan_id: int
|
||||
inspector_id: int
|
||||
status: str = "pending"
|
||||
findings: list[dict] | None = None
|
||||
started_at: str | None = None
|
||||
|
||||
|
||||
class RecordUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
findings: list[dict] | None = None
|
||||
completed_at: str | None = None
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
device_id: int | None = None
|
||||
alarm_event_id: int | None = None
|
||||
priority: str = "medium"
|
||||
cost_estimate: float | None = None
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
priority: str | None = None
|
||||
status: str | None = None
|
||||
resolution: str | None = None
|
||||
actual_cost: float | None = None
|
||||
|
||||
|
||||
class DutyCreate(BaseModel):
|
||||
user_id: int
|
||||
duty_date: str
|
||||
shift: str | None = None
|
||||
area_id: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _plan_to_dict(p: InspectionPlan) -> dict:
|
||||
return {
|
||||
"id": p.id, "name": p.name, "description": p.description,
|
||||
"device_group_id": p.device_group_id, "device_ids": p.device_ids,
|
||||
"schedule_type": p.schedule_type, "schedule_cron": p.schedule_cron,
|
||||
"inspector_id": p.inspector_id, "checklist": p.checklist,
|
||||
"is_active": p.is_active,
|
||||
"next_run_at": str(p.next_run_at) if p.next_run_at else None,
|
||||
"created_by": p.created_by,
|
||||
"created_at": str(p.created_at) if p.created_at else None,
|
||||
"updated_at": str(p.updated_at) if p.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _record_to_dict(r: InspectionRecord) -> dict:
|
||||
return {
|
||||
"id": r.id, "plan_id": r.plan_id, "inspector_id": r.inspector_id,
|
||||
"status": r.status, "findings": r.findings,
|
||||
"started_at": str(r.started_at) if r.started_at else None,
|
||||
"completed_at": str(r.completed_at) if r.completed_at else None,
|
||||
"created_at": str(r.created_at) if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _order_to_dict(o: RepairOrder) -> dict:
|
||||
return {
|
||||
"id": o.id, "code": o.code, "title": o.title, "description": o.description,
|
||||
"device_id": o.device_id, "alarm_event_id": o.alarm_event_id,
|
||||
"priority": o.priority, "status": o.status, "assigned_to": o.assigned_to,
|
||||
"resolution": o.resolution, "cost_estimate": o.cost_estimate,
|
||||
"actual_cost": o.actual_cost, "created_by": o.created_by,
|
||||
"created_at": str(o.created_at) if o.created_at else None,
|
||||
"assigned_at": str(o.assigned_at) if o.assigned_at else None,
|
||||
"completed_at": str(o.completed_at) if o.completed_at else None,
|
||||
"closed_at": str(o.closed_at) if o.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _duty_to_dict(d: DutySchedule) -> dict:
|
||||
return {
|
||||
"id": d.id, "user_id": d.user_id,
|
||||
"duty_date": str(d.duty_date) if d.duty_date else None,
|
||||
"shift": d.shift, "area_id": d.area_id, "notes": d.notes,
|
||||
"created_at": str(d.created_at) if d.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _generate_order_code() -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return f"WO-{now.strftime('%Y%m%d')}-{now.strftime('%H%M%S')}"
|
||||
|
||||
|
||||
# ── Inspection Plans ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/plans")
|
||||
async def list_plans(
|
||||
is_active: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(InspectionPlan).order_by(InspectionPlan.id.desc())
|
||||
if is_active is not None:
|
||||
query = query.where(InspectionPlan.is_active == is_active)
|
||||
result = await db.execute(query)
|
||||
return [_plan_to_dict(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
async def create_plan(
|
||||
data: PlanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
plan = InspectionPlan(**data.model_dump(exclude={"next_run_at"}), created_by=user.id)
|
||||
if data.next_run_at:
|
||||
plan.next_run_at = datetime.fromisoformat(data.next_run_at)
|
||||
db.add(plan)
|
||||
await db.flush()
|
||||
return _plan_to_dict(plan)
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
async def update_plan(
|
||||
plan_id: int,
|
||||
data: PlanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="巡检计划不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True, exclude={"next_run_at"}).items():
|
||||
setattr(plan, k, v)
|
||||
if data.next_run_at:
|
||||
plan.next_run_at = datetime.fromisoformat(data.next_run_at)
|
||||
return _plan_to_dict(plan)
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def delete_plan(
|
||||
plan_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="巡检计划不存在")
|
||||
plan.is_active = False
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/trigger")
|
||||
async def trigger_plan(
|
||||
plan_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""手动触发巡检计划,生成巡检记录"""
|
||||
result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="巡检计划不存在")
|
||||
record = InspectionRecord(
|
||||
plan_id=plan.id,
|
||||
inspector_id=plan.inspector_id or user.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
return _record_to_dict(record)
|
||||
|
||||
|
||||
# ── Inspection Records ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/records")
|
||||
async def list_records(
|
||||
plan_id: int | None = None,
|
||||
status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(InspectionRecord)
|
||||
if plan_id:
|
||||
query = query.where(InspectionRecord.plan_id == plan_id)
|
||||
if status:
|
||||
query = query.where(InspectionRecord.status == status)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(InspectionRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_record_to_dict(r) for r in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/records")
|
||||
async def create_record(
|
||||
data: RecordCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
record = InspectionRecord(**data.model_dump(exclude={"started_at"}))
|
||||
if data.started_at:
|
||||
record.started_at = datetime.fromisoformat(data.started_at)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
return _record_to_dict(record)
|
||||
|
||||
|
||||
@router.put("/records/{record_id}")
|
||||
async def update_record(
|
||||
record_id: int,
|
||||
data: RecordUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(InspectionRecord).where(InspectionRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="巡检记录不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True, exclude={"completed_at"}).items():
|
||||
setattr(record, k, v)
|
||||
if data.completed_at:
|
||||
record.completed_at = datetime.fromisoformat(data.completed_at)
|
||||
elif data.status == "completed" or data.status == "issues_found":
|
||||
record.completed_at = datetime.now(timezone.utc)
|
||||
return _record_to_dict(record)
|
||||
|
||||
|
||||
# ── Repair Orders ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/orders")
|
||||
async def list_orders(
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(RepairOrder)
|
||||
if status:
|
||||
query = query.where(RepairOrder.status == status)
|
||||
if priority:
|
||||
query = query.where(RepairOrder.priority == priority)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(RepairOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_order_to_dict(o) for o in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/orders")
|
||||
async def create_order(
|
||||
data: OrderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = RepairOrder(
|
||||
**data.model_dump(),
|
||||
code=_generate_order_code(),
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
return _order_to_dict(order)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}")
|
||||
async def update_order(
|
||||
order_id: int,
|
||||
data: OrderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(order, k, v)
|
||||
return _order_to_dict(order)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/assign")
|
||||
async def assign_order(
|
||||
order_id: int,
|
||||
assigned_to: int = Query(..., description="指派的用户ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
order.assigned_to = assigned_to
|
||||
order.status = "assigned"
|
||||
order.assigned_at = datetime.now(timezone.utc)
|
||||
return _order_to_dict(order)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/complete")
|
||||
async def complete_order(
|
||||
order_id: int,
|
||||
resolution: str = "",
|
||||
actual_cost: float | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
order.status = "completed"
|
||||
order.resolution = resolution
|
||||
if actual_cost is not None:
|
||||
order.actual_cost = actual_cost
|
||||
order.completed_at = datetime.now(timezone.utc)
|
||||
return _order_to_dict(order)
|
||||
|
||||
|
||||
# ── Duty Schedule ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/duty")
|
||||
async def list_duty(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
user_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(DutySchedule)
|
||||
if start_date:
|
||||
query = query.where(DutySchedule.duty_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(DutySchedule.duty_date <= end_date)
|
||||
if user_id:
|
||||
query = query.where(DutySchedule.user_id == user_id)
|
||||
query = query.order_by(DutySchedule.duty_date)
|
||||
result = await db.execute(query)
|
||||
return [_duty_to_dict(d) for d in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/duty")
|
||||
async def create_duty(
|
||||
data: DutyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
duty = DutySchedule(
|
||||
user_id=data.user_id,
|
||||
duty_date=datetime.fromisoformat(data.duty_date),
|
||||
shift=data.shift,
|
||||
area_id=data.area_id,
|
||||
notes=data.notes,
|
||||
)
|
||||
db.add(duty)
|
||||
await db.flush()
|
||||
return _duty_to_dict(duty)
|
||||
|
||||
|
||||
@router.put("/duty/{duty_id}")
|
||||
async def update_duty(
|
||||
duty_id: int,
|
||||
data: DutyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id))
|
||||
duty = result.scalar_one_or_none()
|
||||
if not duty:
|
||||
raise HTTPException(status_code=404, detail="值班记录不存在")
|
||||
duty.user_id = data.user_id
|
||||
duty.duty_date = datetime.fromisoformat(data.duty_date)
|
||||
duty.shift = data.shift
|
||||
duty.area_id = data.area_id
|
||||
duty.notes = data.notes
|
||||
return _duty_to_dict(duty)
|
||||
|
||||
|
||||
@router.delete("/duty/{duty_id}")
|
||||
async def delete_duty(
|
||||
duty_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id))
|
||||
duty = result.scalar_one_or_none()
|
||||
if not duty:
|
||||
raise HTTPException(status_code=404, detail="值班记录不存在")
|
||||
await db.delete(duty)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ── Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def maintenance_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Open orders count
|
||||
open_q = select(func.count()).select_from(RepairOrder).where(
|
||||
RepairOrder.status.in_(["open", "assigned", "in_progress"])
|
||||
)
|
||||
open_orders = (await db.execute(open_q)).scalar() or 0
|
||||
|
||||
# Overdue: assigned but not completed, assigned_at > 7 days ago
|
||||
from datetime import timedelta
|
||||
overdue_cutoff = now - timedelta(days=7)
|
||||
overdue_q = select(func.count()).select_from(RepairOrder).where(
|
||||
and_(
|
||||
RepairOrder.status.in_(["assigned", "in_progress"]),
|
||||
RepairOrder.assigned_at < overdue_cutoff,
|
||||
)
|
||||
)
|
||||
overdue_count = (await db.execute(overdue_q)).scalar() or 0
|
||||
|
||||
# Today's inspections
|
||||
inspect_q = select(func.count()).select_from(InspectionRecord).where(
|
||||
InspectionRecord.created_at >= today_start,
|
||||
)
|
||||
todays_inspections = (await db.execute(inspect_q)).scalar() or 0
|
||||
|
||||
# Upcoming duties (next 7 days)
|
||||
duty_end = now + timedelta(days=7)
|
||||
duty_q = select(func.count()).select_from(DutySchedule).where(
|
||||
and_(DutySchedule.duty_date >= today_start, DutySchedule.duty_date <= duty_end)
|
||||
)
|
||||
upcoming_duties = (await db.execute(duty_q)).scalar() or 0
|
||||
|
||||
# Recent orders (latest 10)
|
||||
recent_q = select(RepairOrder).order_by(RepairOrder.created_at.desc()).limit(10)
|
||||
recent_result = await db.execute(recent_q)
|
||||
recent_orders = [_order_to_dict(o) for o in recent_result.scalars().all()]
|
||||
|
||||
return {
|
||||
"open_orders": open_orders,
|
||||
"overdue_count": overdue_count,
|
||||
"todays_inspections": todays_inspections,
|
||||
"upcoming_duties": upcoming_duties,
|
||||
"recent_orders": recent_orders,
|
||||
}
|
||||
385
backend/app/api/v1/management.py
Normal file
385
backend/app/api/v1/management.py
Normal file
@@ -0,0 +1,385 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.management import Regulation, Standard, ProcessDoc, EmergencyPlan
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/management", tags=["管理体系"])
|
||||
|
||||
|
||||
# ── Pydantic Schemas ──────────────────────────────────────────────────
|
||||
|
||||
class RegulationCreate(BaseModel):
|
||||
title: str
|
||||
category: str | None = None
|
||||
content: str | None = None
|
||||
effective_date: datetime | None = None
|
||||
status: str = "active"
|
||||
attachment_url: str | None = None
|
||||
|
||||
|
||||
class StandardCreate(BaseModel):
|
||||
name: str
|
||||
code: str | None = None
|
||||
type: str | None = None
|
||||
description: str | None = None
|
||||
compliance_status: str = "pending"
|
||||
review_date: datetime | None = None
|
||||
|
||||
|
||||
class ProcessDocCreate(BaseModel):
|
||||
title: str
|
||||
category: str | None = None
|
||||
content: str | None = None
|
||||
version: str = "1.0"
|
||||
approved_by: str | None = None
|
||||
effective_date: datetime | None = None
|
||||
|
||||
|
||||
class EmergencyPlanCreate(BaseModel):
|
||||
title: str
|
||||
scenario: str | None = None
|
||||
steps: list[dict] | None = None
|
||||
responsible_person: str | None = None
|
||||
review_date: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
# ── Regulations (规章制度) ────────────────────────────────────────────
|
||||
|
||||
@router.get("/regulations")
|
||||
async def list_regulations(
|
||||
category: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(Regulation)
|
||||
if category:
|
||||
query = query.where(Regulation.category == category)
|
||||
if status:
|
||||
query = query.where(Regulation.status == status)
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
query = query.order_by(Regulation.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_regulation_to_dict(r) for r in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/regulations")
|
||||
async def create_regulation(
|
||||
data: RegulationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
reg = Regulation(**data.model_dump(), created_by=user.id)
|
||||
db.add(reg)
|
||||
await db.flush()
|
||||
return _regulation_to_dict(reg)
|
||||
|
||||
|
||||
@router.put("/regulations/{reg_id}")
|
||||
async def update_regulation(
|
||||
reg_id: int,
|
||||
data: RegulationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Regulation).where(Regulation.id == reg_id))
|
||||
reg = result.scalar_one_or_none()
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="规章制度不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(reg, k, v)
|
||||
return _regulation_to_dict(reg)
|
||||
|
||||
|
||||
@router.delete("/regulations/{reg_id}")
|
||||
async def delete_regulation(
|
||||
reg_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Regulation).where(Regulation.id == reg_id))
|
||||
reg = result.scalar_one_or_none()
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="规章制度不存在")
|
||||
await db.delete(reg)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ── Standards (标准规范) ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/standards")
|
||||
async def list_standards(
|
||||
type: str | None = None,
|
||||
compliance_status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(Standard)
|
||||
if type:
|
||||
query = query.where(Standard.type == type)
|
||||
if compliance_status:
|
||||
query = query.where(Standard.compliance_status == compliance_status)
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
query = query.order_by(Standard.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_standard_to_dict(s) for s in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/standards")
|
||||
async def create_standard(
|
||||
data: StandardCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
std = Standard(**data.model_dump())
|
||||
db.add(std)
|
||||
await db.flush()
|
||||
return _standard_to_dict(std)
|
||||
|
||||
|
||||
@router.put("/standards/{std_id}")
|
||||
async def update_standard(
|
||||
std_id: int,
|
||||
data: StandardCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Standard).where(Standard.id == std_id))
|
||||
std = result.scalar_one_or_none()
|
||||
if not std:
|
||||
raise HTTPException(status_code=404, detail="标准规范不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(std, k, v)
|
||||
return _standard_to_dict(std)
|
||||
|
||||
|
||||
@router.delete("/standards/{std_id}")
|
||||
async def delete_standard(
|
||||
std_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Standard).where(Standard.id == std_id))
|
||||
std = result.scalar_one_or_none()
|
||||
if not std:
|
||||
raise HTTPException(status_code=404, detail="标准规范不存在")
|
||||
await db.delete(std)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ── Process Docs (管理流程) ───────────────────────────────────────────
|
||||
|
||||
@router.get("/process-docs")
|
||||
async def list_process_docs(
|
||||
category: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(ProcessDoc)
|
||||
if category:
|
||||
query = query.where(ProcessDoc.category == category)
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
query = query.order_by(ProcessDoc.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_process_doc_to_dict(d) for d in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/process-docs")
|
||||
async def create_process_doc(
|
||||
data: ProcessDocCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
doc = ProcessDoc(**data.model_dump())
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
return _process_doc_to_dict(doc)
|
||||
|
||||
|
||||
@router.put("/process-docs/{doc_id}")
|
||||
async def update_process_doc(
|
||||
doc_id: int,
|
||||
data: ProcessDocCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id))
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="管理流程文档不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(doc, k, v)
|
||||
return _process_doc_to_dict(doc)
|
||||
|
||||
|
||||
@router.delete("/process-docs/{doc_id}")
|
||||
async def delete_process_doc(
|
||||
doc_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id))
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="管理流程文档不存在")
|
||||
await db.delete(doc)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ── Emergency Plans (应急预案) ────────────────────────────────────────
|
||||
|
||||
@router.get("/emergency-plans")
|
||||
async def list_emergency_plans(
|
||||
scenario: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(EmergencyPlan)
|
||||
if scenario:
|
||||
query = query.where(EmergencyPlan.scenario == scenario)
|
||||
if is_active is not None:
|
||||
query = query.where(EmergencyPlan.is_active == is_active)
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
query = query.order_by(EmergencyPlan.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_emergency_plan_to_dict(p) for p in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/emergency-plans")
|
||||
async def create_emergency_plan(
|
||||
data: EmergencyPlanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
plan = EmergencyPlan(**data.model_dump())
|
||||
db.add(plan)
|
||||
await db.flush()
|
||||
return _emergency_plan_to_dict(plan)
|
||||
|
||||
|
||||
@router.put("/emergency-plans/{plan_id}")
|
||||
async def update_emergency_plan(
|
||||
plan_id: int,
|
||||
data: EmergencyPlanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="应急预案不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(plan, k, v)
|
||||
return _emergency_plan_to_dict(plan)
|
||||
|
||||
|
||||
@router.delete("/emergency-plans/{plan_id}")
|
||||
async def delete_emergency_plan(
|
||||
plan_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="应急预案不存在")
|
||||
await db.delete(plan)
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ── Compliance Overview ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/compliance")
|
||||
async def compliance_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""合规概览 - count by compliance_status for standards"""
|
||||
result = await db.execute(
|
||||
select(Standard.compliance_status, func.count(Standard.id))
|
||||
.group_by(Standard.compliance_status)
|
||||
)
|
||||
stats = {row[0]: row[1] for row in result.all()}
|
||||
return {
|
||||
"compliant": stats.get("compliant", 0),
|
||||
"non_compliant": stats.get("non_compliant", 0),
|
||||
"pending": stats.get("pending", 0),
|
||||
"in_progress": stats.get("in_progress", 0),
|
||||
"total": sum(stats.values()),
|
||||
}
|
||||
|
||||
|
||||
# ── Serializers ───────────────────────────────────────────────────────
|
||||
|
||||
def _regulation_to_dict(r: Regulation) -> dict:
|
||||
return {
|
||||
"id": r.id, "title": r.title, "category": r.category,
|
||||
"content": r.content, "effective_date": str(r.effective_date) if r.effective_date else None,
|
||||
"status": r.status, "attachment_url": r.attachment_url,
|
||||
"created_by": r.created_by,
|
||||
"created_at": str(r.created_at) if r.created_at else None,
|
||||
"updated_at": str(r.updated_at) if r.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _standard_to_dict(s: Standard) -> dict:
|
||||
return {
|
||||
"id": s.id, "name": s.name, "code": s.code, "type": s.type,
|
||||
"description": s.description, "compliance_status": s.compliance_status,
|
||||
"review_date": str(s.review_date) if s.review_date else None,
|
||||
"created_at": str(s.created_at) if s.created_at else None,
|
||||
"updated_at": str(s.updated_at) if s.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _process_doc_to_dict(d: ProcessDoc) -> dict:
|
||||
return {
|
||||
"id": d.id, "title": d.title, "category": d.category,
|
||||
"content": d.content, "version": d.version,
|
||||
"approved_by": d.approved_by,
|
||||
"effective_date": str(d.effective_date) if d.effective_date else None,
|
||||
"created_at": str(d.created_at) if d.created_at else None,
|
||||
"updated_at": str(d.updated_at) if d.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _emergency_plan_to_dict(p: EmergencyPlan) -> dict:
|
||||
return {
|
||||
"id": p.id, "title": p.title, "scenario": p.scenario,
|
||||
"steps": p.steps, "responsible_person": p.responsible_person,
|
||||
"review_date": str(p.review_date) if p.review_date else None,
|
||||
"is_active": p.is_active,
|
||||
"created_at": str(p.created_at) if p.created_at else None,
|
||||
"updated_at": str(p.updated_at) if p.updated_at else None,
|
||||
}
|
||||
78
backend/app/api/v1/monitoring.py
Normal file
78
backend/app/api/v1/monitoring.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/monitoring", tags=["实时监控"])
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/realtime")
|
||||
async def device_realtime(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""获取单台设备的最新实时数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
result = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.device_id == device_id, EnergyData.timestamp >= five_min_ago)
|
||||
).order_by(EnergyData.timestamp.desc()).limit(20)
|
||||
)
|
||||
data_points = result.scalars().all()
|
||||
latest = {}
|
||||
for d in data_points:
|
||||
if d.data_type not in latest:
|
||||
latest[d.data_type] = {"value": d.value, "unit": d.unit, "timestamp": str(d.timestamp)}
|
||||
|
||||
device_q = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = device_q.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"id": device.id, "name": device.name, "code": device.code,
|
||||
"device_type": device.device_type, "status": device.status,
|
||||
"model": device.model, "manufacturer": device.manufacturer,
|
||||
} if device else None,
|
||||
"data": latest,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/energy-flow")
|
||||
async def energy_flow(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""能流图数据 - 展示能量流向"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
# 获取各类设备最新功率
|
||||
result = await db.execute(
|
||||
select(Device.device_type, func.sum(EnergyData.value))
|
||||
.join(EnergyData, EnergyData.device_id == Device.id)
|
||||
.where(and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power"))
|
||||
.group_by(Device.device_type)
|
||||
)
|
||||
power_by_type = {row[0]: round(row[1], 2) for row in result.all()}
|
||||
|
||||
pv_power = power_by_type.get("pv_inverter", 0)
|
||||
hp_power = power_by_type.get("heat_pump", 0)
|
||||
total_load = hp_power + power_by_type.get("meter", 0)
|
||||
grid_import = max(0, total_load - pv_power)
|
||||
grid_export = max(0, pv_power - total_load)
|
||||
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "pv", "name": "光伏发电", "power": pv_power, "unit": "kW"},
|
||||
{"id": "grid", "name": "电网", "power": grid_import - grid_export, "unit": "kW"},
|
||||
{"id": "heatpump", "name": "热泵系统", "power": hp_power, "unit": "kW"},
|
||||
{"id": "building", "name": "建筑负荷", "power": total_load, "unit": "kW"},
|
||||
],
|
||||
"links": [
|
||||
{"source": "pv", "target": "building", "value": min(pv_power, total_load)},
|
||||
{"source": "pv", "target": "grid", "value": grid_export},
|
||||
{"source": "grid", "target": "building", "value": grid_import},
|
||||
{"source": "grid", "target": "heatpump", "value": hp_power},
|
||||
]
|
||||
}
|
||||
185
backend/app/api/v1/prediction.py
Normal file
185
backend/app/api/v1/prediction.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""AI预测引擎 API - 光伏/负荷/热泵预测 & 自发自用优化"""
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User
|
||||
from app.models.prediction import PredictionTask, PredictionResult, OptimizationSchedule
|
||||
from app.services.ai_prediction import (
|
||||
forecast_pv, forecast_load, forecast_heatpump_cop,
|
||||
optimize_self_consumption, get_prediction_accuracy, run_prediction,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/prediction", tags=["AI预测"])
|
||||
|
||||
|
||||
# ── Schemas ────────────────────────────────────────────────────────────
|
||||
|
||||
class RunPredictionRequest(BaseModel):
|
||||
device_id: Optional[int] = None
|
||||
prediction_type: str # pv, load, heatpump, optimization
|
||||
horizon_hours: int = 24
|
||||
parameters: Optional[dict] = None
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/forecast")
|
||||
async def get_forecast(
|
||||
device_id: Optional[int] = None,
|
||||
type: str = Query("pv", pattern="^(pv|load|heatpump)$"),
|
||||
horizon: int = Query(24, ge=1, le=168),
|
||||
building_type: str = Query("office", pattern="^(office|factory)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取预测结果 - PV发电/负荷/热泵COP"""
|
||||
if type == "pv":
|
||||
if not device_id:
|
||||
raise HTTPException(400, "光伏预测需要指定device_id")
|
||||
return await forecast_pv(db, device_id, horizon)
|
||||
elif type == "load":
|
||||
return await forecast_load(db, device_id, building_type, horizon)
|
||||
elif type == "heatpump":
|
||||
if not device_id:
|
||||
raise HTTPException(400, "热泵预测需要指定device_id")
|
||||
return await forecast_heatpump_cop(db, device_id, horizon)
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def trigger_prediction(
|
||||
req: RunPredictionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""触发新的预测任务"""
|
||||
task = await run_prediction(
|
||||
db, req.device_id, req.prediction_type,
|
||||
req.horizon_hours, req.parameters,
|
||||
)
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"status": task.status,
|
||||
"prediction_type": task.prediction_type,
|
||||
"horizon_hours": task.horizon_hours,
|
||||
"error_message": task.error_message,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accuracy")
|
||||
async def prediction_accuracy(
|
||||
type: Optional[str] = Query(None, pattern="^(pv|load|heatpump|optimization)$"),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取预测精度指标 (MAE, RMSE, MAPE)"""
|
||||
return await get_prediction_accuracy(db, type, days)
|
||||
|
||||
|
||||
@router.get("/optimization")
|
||||
async def get_optimization(
|
||||
horizon: int = Query(24, ge=1, le=72),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取自发自用优化建议"""
|
||||
return await optimize_self_consumption(db, horizon)
|
||||
|
||||
|
||||
@router.post("/optimization/{schedule_id}/approve")
|
||||
async def approve_optimization(
|
||||
schedule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""审批优化调度方案"""
|
||||
result = await db.execute(
|
||||
select(OptimizationSchedule).where(OptimizationSchedule.id == schedule_id)
|
||||
)
|
||||
schedule = result.scalar_one_or_none()
|
||||
if not schedule:
|
||||
raise HTTPException(404, "优化方案不存在")
|
||||
if schedule.status != "pending":
|
||||
raise HTTPException(400, f"方案状态为 {schedule.status},无法审批")
|
||||
|
||||
schedule.status = "approved"
|
||||
schedule.approved_by = user.id
|
||||
schedule.approved_at = datetime.now(timezone.utc)
|
||||
return {"id": schedule.id, "status": "approved"}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def prediction_history(
|
||||
type: Optional[str] = Query(None),
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""历史预测任务列表"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
conditions = [PredictionTask.created_at >= cutoff]
|
||||
if type:
|
||||
conditions.append(PredictionTask.prediction_type == type)
|
||||
|
||||
query = (
|
||||
select(PredictionTask)
|
||||
.where(and_(*conditions))
|
||||
.order_by(PredictionTask.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return [{
|
||||
"id": t.id,
|
||||
"device_id": t.device_id,
|
||||
"prediction_type": t.prediction_type,
|
||||
"horizon_hours": t.horizon_hours,
|
||||
"status": t.status,
|
||||
"created_at": str(t.created_at) if t.created_at else None,
|
||||
"completed_at": str(t.completed_at) if t.completed_at else None,
|
||||
"error_message": t.error_message,
|
||||
} for t in tasks]
|
||||
|
||||
|
||||
@router.get("/schedules")
|
||||
async def list_schedules(
|
||||
status: Optional[str] = Query(None, pattern="^(pending|approved|executed|rejected)$"),
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取优化调度方案列表"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
conditions = [OptimizationSchedule.created_at >= cutoff]
|
||||
if status:
|
||||
conditions.append(OptimizationSchedule.status == status)
|
||||
|
||||
result = await db.execute(
|
||||
select(OptimizationSchedule)
|
||||
.where(and_(*conditions))
|
||||
.order_by(OptimizationSchedule.created_at.desc())
|
||||
)
|
||||
schedules = result.scalars().all()
|
||||
|
||||
return [{
|
||||
"id": s.id,
|
||||
"device_id": s.device_id,
|
||||
"date": str(s.date) if s.date else None,
|
||||
"expected_savings_kwh": s.expected_savings_kwh,
|
||||
"expected_savings_yuan": s.expected_savings_yuan,
|
||||
"status": s.status,
|
||||
"schedule_data": s.schedule_data,
|
||||
"created_at": str(s.created_at) if s.created_at else None,
|
||||
} for s in schedules]
|
||||
192
backend/app/api/v1/quota.py
Normal file
192
backend/app/api/v1/quota.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.quota import EnergyQuota, QuotaUsage
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/quota", tags=["配额管理"])
|
||||
|
||||
|
||||
class QuotaCreate(BaseModel):
|
||||
name: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
energy_type: str
|
||||
period: str
|
||||
quota_value: float
|
||||
unit: str = "kWh"
|
||||
warning_threshold_pct: float = 80
|
||||
alert_threshold_pct: float = 95
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_quotas(
|
||||
target_type: str | None = None,
|
||||
energy_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出所有配额,附带当前使用率"""
|
||||
query = select(EnergyQuota).where(EnergyQuota.is_active == True)
|
||||
if target_type:
|
||||
query = query.where(EnergyQuota.target_type == target_type)
|
||||
if energy_type:
|
||||
query = query.where(EnergyQuota.energy_type == energy_type)
|
||||
query = query.order_by(EnergyQuota.id.desc())
|
||||
result = await db.execute(query)
|
||||
quotas = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for q in quotas:
|
||||
# 获取最新使用记录
|
||||
usage_result = await db.execute(
|
||||
select(QuotaUsage)
|
||||
.where(QuotaUsage.quota_id == q.id)
|
||||
.order_by(QuotaUsage.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
usage = usage_result.scalar_one_or_none()
|
||||
items.append({
|
||||
**_quota_to_dict(q),
|
||||
"current_usage": usage.actual_value if usage else 0,
|
||||
"usage_rate_pct": usage.usage_rate_pct if usage else 0,
|
||||
"usage_status": usage.status if usage else "normal",
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_quota(
|
||||
data: QuotaCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
quota = EnergyQuota(**data.model_dump(), created_by=user.id)
|
||||
db.add(quota)
|
||||
await db.flush()
|
||||
return _quota_to_dict(quota)
|
||||
|
||||
|
||||
@router.put("/{quota_id}")
|
||||
async def update_quota(
|
||||
quota_id: int,
|
||||
data: QuotaCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id))
|
||||
quota = result.scalar_one_or_none()
|
||||
if not quota:
|
||||
raise HTTPException(status_code=404, detail="配额不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(quota, k, v)
|
||||
return _quota_to_dict(quota)
|
||||
|
||||
|
||||
@router.delete("/{quota_id}")
|
||||
async def delete_quota(
|
||||
quota_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id))
|
||||
quota = result.scalar_one_or_none()
|
||||
if not quota:
|
||||
raise HTTPException(status_code=404, detail="配额不存在")
|
||||
quota.is_active = False
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def quota_usage(
|
||||
target_type: str | None = None,
|
||||
energy_type: str | None = None,
|
||||
period: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""配额使用统计,支持筛选和分页"""
|
||||
query = (
|
||||
select(QuotaUsage)
|
||||
.join(EnergyQuota, QuotaUsage.quota_id == EnergyQuota.id)
|
||||
.where(EnergyQuota.is_active == True)
|
||||
)
|
||||
if target_type:
|
||||
query = query.where(EnergyQuota.target_type == target_type)
|
||||
if energy_type:
|
||||
query = query.where(EnergyQuota.energy_type == energy_type)
|
||||
if period:
|
||||
query = query.where(EnergyQuota.period == period)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(QuotaUsage.calculated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": u.id, "quota_id": u.quota_id,
|
||||
"period_start": str(u.period_start), "period_end": str(u.period_end),
|
||||
"actual_value": u.actual_value, "quota_value": u.quota_value,
|
||||
"usage_rate_pct": u.usage_rate_pct, "status": u.status,
|
||||
"calculated_at": str(u.calculated_at),
|
||||
} for u in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance")
|
||||
async def quota_compliance(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""配额合规概览:统计各状态数量"""
|
||||
# 每个活跃配额的最新使用记录
|
||||
quotas_result = await db.execute(
|
||||
select(EnergyQuota).where(EnergyQuota.is_active == True)
|
||||
)
|
||||
quotas = quotas_result.scalars().all()
|
||||
|
||||
summary = {"total": 0, "normal": 0, "warning": 0, "exceeded": 0}
|
||||
details = []
|
||||
|
||||
for q in quotas:
|
||||
usage_result = await db.execute(
|
||||
select(QuotaUsage)
|
||||
.where(QuotaUsage.quota_id == q.id)
|
||||
.order_by(QuotaUsage.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
usage = usage_result.scalar_one_or_none()
|
||||
status = usage.status if usage else "normal"
|
||||
summary["total"] += 1
|
||||
summary[status] = summary.get(status, 0) + 1
|
||||
details.append({
|
||||
"quota_id": q.id,
|
||||
"name": q.name,
|
||||
"target_type": q.target_type,
|
||||
"energy_type": q.energy_type,
|
||||
"quota_value": q.quota_value,
|
||||
"actual_value": usage.actual_value if usage else 0,
|
||||
"usage_rate_pct": usage.usage_rate_pct if usage else 0,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
return {"summary": summary, "details": details}
|
||||
|
||||
|
||||
def _quota_to_dict(q: EnergyQuota) -> dict:
|
||||
return {
|
||||
"id": q.id, "name": q.name, "target_type": q.target_type,
|
||||
"target_id": q.target_id, "energy_type": q.energy_type,
|
||||
"period": q.period, "quota_value": q.quota_value, "unit": q.unit,
|
||||
"warning_threshold_pct": q.warning_threshold_pct,
|
||||
"alert_threshold_pct": q.alert_threshold_pct,
|
||||
"is_active": q.is_active,
|
||||
}
|
||||
316
backend/app/api/v1/reports.py
Normal file
316
backend/app/api/v1/reports.py
Normal file
@@ -0,0 +1,316 @@
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.report import ReportTemplate, ReportTask
|
||||
from app.models.user import User
|
||||
from app.services.report_generator import ReportGenerator, REPORTS_DIR
|
||||
from app.services.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["报表管理"])
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
report_type: str
|
||||
description: str | None = None
|
||||
fields: list[dict]
|
||||
filters: dict | None = None
|
||||
aggregation: str = "sum"
|
||||
time_granularity: str = "hour"
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
template_id: int
|
||||
name: str | None = None
|
||||
schedule: str | None = None
|
||||
recipients: list[str] | None = None
|
||||
export_format: str = "xlsx"
|
||||
|
||||
|
||||
class QuickReportRequest(BaseModel):
|
||||
report_type: str # daily, monthly, device_status, alarm, carbon
|
||||
export_format: str = "xlsx"
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
month: int | None = None
|
||||
year: int | None = None
|
||||
device_ids: list[int] | None = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Templates
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ReportTemplate).order_by(ReportTemplate.id))
|
||||
return [{
|
||||
"id": t.id, "name": t.name, "report_type": t.report_type, "description": t.description,
|
||||
"fields": t.fields, "is_system": t.is_system, "aggregation": t.aggregation,
|
||||
"time_granularity": t.time_granularity,
|
||||
} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(data: TemplateCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
template = ReportTemplate(**data.model_dump(), created_by=user.id)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
return {"id": template.id, "name": template.name}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tasks CRUD
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ReportTask).order_by(ReportTask.id.desc()))
|
||||
return [{
|
||||
"id": t.id, "template_id": t.template_id, "name": t.name, "schedule": t.schedule,
|
||||
"status": t.status, "export_format": t.export_format, "file_path": t.file_path,
|
||||
"last_run": str(t.last_run) if t.last_run else None,
|
||||
} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
task = ReportTask(**data.model_dump(), created_by=user.id)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
return {"id": task.id}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Run / Status / Download
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
REPORT_TYPE_METHODS = {
|
||||
"daily": "generate_energy_daily_report",
|
||||
"monthly": "generate_monthly_summary",
|
||||
"device_status": "generate_device_status_report",
|
||||
"alarm": "generate_alarm_report",
|
||||
"carbon": "generate_carbon_report",
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(val, default: date) -> date:
|
||||
if not val:
|
||||
return default
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
try:
|
||||
return date.fromisoformat(str(val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/run")
|
||||
async def run_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(ReportTask).where(ReportTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# Try Celery first
|
||||
try:
|
||||
from app.core.config import get_settings
|
||||
from app.tasks.report_tasks import CELERY_AVAILABLE
|
||||
if CELERY_AVAILABLE and get_settings().CELERY_ENABLED:
|
||||
task.status = "running"
|
||||
await db.flush()
|
||||
from app.tasks.report_tasks import generate_report_task
|
||||
generate_report_task.delay(task_id)
|
||||
return {"message": "报表生成任务已提交(异步)", "task_id": task.id, "mode": "async"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Inline async generation (avoids event loop issues with BackgroundTasks)
|
||||
task.status = "running"
|
||||
await db.flush()
|
||||
|
||||
template = (await db.execute(
|
||||
select(ReportTemplate).where(ReportTemplate.id == task.template_id)
|
||||
)).scalar_one_or_none()
|
||||
if not template:
|
||||
task.status = "failed"
|
||||
await db.flush()
|
||||
raise HTTPException(status_code=400, detail=f"模板 {task.template_id} 不存在")
|
||||
|
||||
filters = template.filters or {}
|
||||
today = date.today()
|
||||
start_date = _parse_date(filters.get("start_date"), default=today.replace(day=1))
|
||||
end_date = _parse_date(filters.get("end_date"), default=today)
|
||||
device_ids = filters.get("device_ids")
|
||||
export_format = task.export_format or "xlsx"
|
||||
report_type = template.report_type
|
||||
|
||||
method_name = REPORT_TYPE_METHODS.get(report_type)
|
||||
if not method_name:
|
||||
task.status = "failed"
|
||||
await db.flush()
|
||||
raise HTTPException(status_code=400, detail=f"未知报表类型: {report_type}")
|
||||
|
||||
try:
|
||||
gen = ReportGenerator(db)
|
||||
method = getattr(gen, method_name)
|
||||
if report_type == "monthly":
|
||||
month = filters.get("month", today.month)
|
||||
year = filters.get("year", today.year)
|
||||
filepath = await method(month=month, year=year, export_format=export_format)
|
||||
elif report_type == "device_status":
|
||||
filepath = await method(export_format=export_format)
|
||||
else:
|
||||
kwargs = {"start_date": start_date, "end_date": end_date, "export_format": export_format}
|
||||
if device_ids and report_type == "daily":
|
||||
kwargs["device_ids"] = device_ids
|
||||
filepath = await method(**kwargs)
|
||||
|
||||
task.status = "completed"
|
||||
task.file_path = filepath
|
||||
task.last_run = datetime.now()
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "export", "report", detail=f"运行报表任务 #{task_id}")
|
||||
logger.info(f"Report task {task_id} completed: {filepath}")
|
||||
return {"message": "报表生成完成", "task_id": task.id, "mode": "sync", "status": "completed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Report task {task_id} failed: {e}")
|
||||
task.status = "failed"
|
||||
await db.flush()
|
||||
raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/status")
|
||||
async def task_status(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(ReportTask).where(ReportTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return {
|
||||
"id": task.id,
|
||||
"status": task.status,
|
||||
"file_path": task.file_path,
|
||||
"last_run": str(task.last_run) if task.last_run else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/download")
|
||||
async def download_report(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(ReportTask).where(ReportTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task.status != "completed" or not task.file_path:
|
||||
raise HTTPException(status_code=400, detail="报表尚未生成完成")
|
||||
if not Path(task.file_path).exists():
|
||||
raise HTTPException(status_code=404, detail="报表文件不存在")
|
||||
|
||||
filename = Path(task.file_path).name
|
||||
media_type = (
|
||||
"application/pdf" if filename.endswith(".pdf")
|
||||
else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
return FileResponse(task.file_path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Quick report (synchronous, no task record needed)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_quick_report(
|
||||
req: QuickReportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate a report synchronously and return the download URL.
|
||||
Useful for demo and quick one-off reports without creating a task record.
|
||||
"""
|
||||
gen = ReportGenerator(db)
|
||||
today = date.today()
|
||||
|
||||
try:
|
||||
if req.report_type == "daily":
|
||||
filepath = await gen.generate_energy_daily_report(
|
||||
start_date=req.start_date or today.replace(day=1),
|
||||
end_date=req.end_date or today,
|
||||
device_ids=req.device_ids,
|
||||
export_format=req.export_format,
|
||||
)
|
||||
elif req.report_type == "monthly":
|
||||
filepath = await gen.generate_monthly_summary(
|
||||
month=req.month or today.month,
|
||||
year=req.year or today.year,
|
||||
export_format=req.export_format,
|
||||
)
|
||||
elif req.report_type == "device_status":
|
||||
filepath = await gen.generate_device_status_report(
|
||||
export_format=req.export_format,
|
||||
)
|
||||
elif req.report_type == "alarm":
|
||||
filepath = await gen.generate_alarm_report(
|
||||
start_date=req.start_date or today.replace(day=1),
|
||||
end_date=req.end_date or today,
|
||||
export_format=req.export_format,
|
||||
)
|
||||
elif req.report_type == "carbon":
|
||||
filepath = await gen.generate_carbon_report(
|
||||
start_date=req.start_date or today.replace(day=1),
|
||||
end_date=req.end_date or today,
|
||||
export_format=req.export_format,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"未知的报表类型: {req.report_type}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}")
|
||||
|
||||
filename = Path(filepath).name
|
||||
await log_audit(db, user.id, "export", "report", detail=f"生成报表: {req.report_type} ({req.export_format})")
|
||||
return {
|
||||
"message": "报表生成成功",
|
||||
"filename": filename,
|
||||
"download_url": f"/api/v1/reports/download/{filename}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/download/{filename}")
|
||||
async def download_by_filename(
|
||||
filename: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download a generated report file by filename."""
|
||||
filepath = REPORTS_DIR / filename
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
# Prevent path traversal
|
||||
if not filepath.resolve().parent == REPORTS_DIR.resolve():
|
||||
raise HTTPException(status_code=400, detail="非法文件路径")
|
||||
|
||||
media_type = (
|
||||
"application/pdf" if filename.endswith(".pdf")
|
||||
else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
return FileResponse(str(filepath), filename=filename, media_type=media_type)
|
||||
84
backend/app/api/v1/settings.py
Normal file
84
backend/app/api/v1/settings.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User
|
||||
from app.models.setting import SystemSetting
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
# Default settings — used when keys are missing from DB
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"platform_name": "天普零碳园区智慧能源管理平台",
|
||||
"data_retention_days": "365",
|
||||
"alarm_auto_resolve_minutes": "30",
|
||||
"simulator_interval_seconds": "15",
|
||||
"notification_email_enabled": "false",
|
||||
"notification_email_smtp": "",
|
||||
"report_auto_schedule_enabled": "false",
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
platform_name: str | None = None
|
||||
data_retention_days: int | None = None
|
||||
alarm_auto_resolve_minutes: int | None = None
|
||||
simulator_interval_seconds: int | None = None
|
||||
notification_email_enabled: bool | None = None
|
||||
notification_email_smtp: str | None = None
|
||||
report_auto_schedule_enabled: bool | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all platform settings as a flat dict."""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
db_settings = {s.key: s.value for s in result.scalars().all()}
|
||||
# Merge defaults with DB values
|
||||
merged = {**DEFAULTS, **db_settings}
|
||||
# Cast types for frontend
|
||||
return {
|
||||
"platform_name": merged["platform_name"],
|
||||
"data_retention_days": int(merged["data_retention_days"]),
|
||||
"alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
|
||||
"simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
|
||||
"notification_email_enabled": merged["notification_email_enabled"] == "true",
|
||||
"notification_email_smtp": merged["notification_email_smtp"],
|
||||
"report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
|
||||
"timezone": merged["timezone"],
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
data: SettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin")),
|
||||
):
|
||||
"""Update platform settings (admin only)."""
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
changed_keys = []
|
||||
for key, value in updates.items():
|
||||
str_value = str(value).lower() if isinstance(value, bool) else str(value)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = str_value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=str_value))
|
||||
changed_keys.append(key)
|
||||
|
||||
await log_audit(
|
||||
db, user.id, "update", "system",
|
||||
detail=f"更新系统设置: {', '.join(changed_keys)}",
|
||||
)
|
||||
|
||||
return {"message": "设置已更新"}
|
||||
83
backend/app/api/v1/users.py
Normal file
83
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, Role
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
role: str = "visitor"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
count_q = select(func.count(User.id))
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
result = await db.execute(select(User).offset((page - 1) * page_size).limit(page_size).order_by(User.id))
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||
"email": u.email, "phone": u.phone, "role": u.role,
|
||||
"is_active": u.is_active, "last_login": str(u.last_login) if u.last_login else None,
|
||||
} for u in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||
existing = await db.execute(select(User).where(User.username == data.username))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
new_user = User(
|
||||
username=data.username, hashed_password=hash_password(data.password),
|
||||
full_name=data.full_name, email=data.email, phone=data.phone, role=data.role,
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "user", detail=f"创建用户 {data.username}")
|
||||
return {"id": new_user.id, "username": new_user.username}
|
||||
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(target, k, v)
|
||||
await log_audit(db, admin.id, "update", "user", detail=f"更新用户 {target.username}: {', '.join(updates.keys())}")
|
||||
return {"message": "已更新"}
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_roles(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(Role).order_by(Role.id))
|
||||
return [{"id": r.id, "name": r.name, "display_name": r.display_name, "description": r.description}
|
||||
for r in result.scalars().all()]
|
||||
83
backend/app/api/v1/weather.py
Normal file
83
backend/app/api/v1/weather.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.weather_service import (
|
||||
get_current_weather, get_forecast, get_weather_history,
|
||||
get_weather_impact, get_weather_config, update_weather_config,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/weather", tags=["气象数据"])
|
||||
|
||||
|
||||
class WeatherConfigUpdate(BaseModel):
|
||||
api_provider: str | None = None
|
||||
api_key: str | None = None
|
||||
location_lat: float | None = None
|
||||
location_lon: float | None = None
|
||||
fetch_interval_minutes: int | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def current_weather(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前天气"""
|
||||
return await get_current_weather(db)
|
||||
|
||||
|
||||
@router.get("/forecast")
|
||||
async def weather_forecast(
|
||||
hours: int = Query(default=72, ge=1, le=168),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取72h天气预报"""
|
||||
return await get_forecast(db, hours)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def weather_history(
|
||||
start_date: str = Query(..., description="开始日期 e.g. 2026-03-01"),
|
||||
end_date: str = Query(..., description="结束日期 e.g. 2026-04-01"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取历史天气数据"""
|
||||
start = datetime.fromisoformat(start_date)
|
||||
end = datetime.fromisoformat(end_date)
|
||||
return await get_weather_history(db, start, end)
|
||||
|
||||
|
||||
@router.get("/impact")
|
||||
async def weather_impact(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""天气对能源的影响分析"""
|
||||
return await get_weather_impact(db, days)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取气象API配置"""
|
||||
return await get_weather_config(db)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def set_config(
|
||||
data: WeatherConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新气象API配置"""
|
||||
return await update_weather_config(db, data.model_dump(exclude_none=True))
|
||||
227
backend/app/api/v1/websocket.py
Normal file
227
backend/app/api/v1/websocket.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
WebSocket endpoint for real-time data push.
|
||||
|
||||
Provides instant updates to connected clients (BigScreen, dashboards)
|
||||
instead of relying solely on polling.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.database import async_session
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.alarm import AlarmEvent
|
||||
|
||||
logger = logging.getLogger("app.websocket")
|
||||
|
||||
router = APIRouter(tags=["WebSocket"])
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages active WebSocket connections."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
logger.info(f"WebSocket connected. Total: {len(self.active_connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
logger.info(f"WebSocket disconnected. Total: {len(self.active_connections)}")
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Send message to all connected clients, removing dead connections."""
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
disconnected.append(connection)
|
||||
for conn in disconnected:
|
||||
self.disconnect(conn)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Background task reference
|
||||
_broadcast_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def get_realtime_snapshot() -> dict:
|
||||
"""Fetch latest realtime data from the database.
|
||||
|
||||
Mirrors the logic in dashboard.get_realtime_data:
|
||||
- Query recent power data points (last 5 min)
|
||||
- Aggregate by device type (PV inverters vs heat pumps)
|
||||
"""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
# Get recent power data points
|
||||
latest_q = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= five_min_ago,
|
||||
EnergyData.data_type == "power",
|
||||
)
|
||||
).order_by(EnergyData.timestamp.desc()).limit(50)
|
||||
)
|
||||
data_points = latest_q.scalars().all()
|
||||
|
||||
# Get PV and heat pump device IDs
|
||||
pv_q = await db.execute(
|
||||
select(Device.id).where(
|
||||
Device.device_type == "pv_inverter",
|
||||
Device.is_active == True,
|
||||
)
|
||||
)
|
||||
pv_ids = {r[0] for r in pv_q.fetchall()}
|
||||
|
||||
hp_q = await db.execute(
|
||||
select(Device.id).where(
|
||||
Device.device_type == "heat_pump",
|
||||
Device.is_active == True,
|
||||
)
|
||||
)
|
||||
hp_ids = {r[0] for r in hp_q.fetchall()}
|
||||
|
||||
pv_power = sum(d.value for d in data_points if d.device_id in pv_ids)
|
||||
heatpump_power = sum(d.value for d in data_points if d.device_id in hp_ids)
|
||||
total_load = pv_power + heatpump_power
|
||||
grid_power = max(0, heatpump_power - pv_power)
|
||||
|
||||
# Count active alarms
|
||||
alarm_count_q = await db.execute(
|
||||
select(func.count(AlarmEvent.id)).where(
|
||||
AlarmEvent.status == 'active'
|
||||
)
|
||||
)
|
||||
active_alarms = alarm_count_q.scalar() or 0
|
||||
|
||||
return {
|
||||
"pv_power": round(pv_power, 1),
|
||||
"heatpump_power": round(heatpump_power, 1),
|
||||
"total_load": round(total_load, 1),
|
||||
"grid_power": round(grid_power, 1),
|
||||
"active_alarms": active_alarms,
|
||||
"timestamp": now.isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching realtime snapshot: {e}")
|
||||
return {
|
||||
"pv_power": 0,
|
||||
"heatpump_power": 0,
|
||||
"total_load": 0,
|
||||
"grid_power": 0,
|
||||
"active_alarms": 0,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_loop():
|
||||
"""Background task: broadcast realtime data every 15 seconds."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(15)
|
||||
if manager.active_connections:
|
||||
data = await get_realtime_snapshot()
|
||||
await manager.broadcast({
|
||||
"type": "realtime_update",
|
||||
"data": data,
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Broadcast loop error: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def broadcast_alarm_event(alarm_data: dict):
|
||||
"""Called externally when a new alarm is triggered."""
|
||||
if manager.active_connections:
|
||||
await manager.broadcast({
|
||||
"type": "alarm_event",
|
||||
"data": alarm_data,
|
||||
})
|
||||
|
||||
|
||||
def start_broadcast_task():
|
||||
"""Start the background broadcast loop. Call during app startup."""
|
||||
global _broadcast_task
|
||||
if _broadcast_task is None or _broadcast_task.done():
|
||||
_broadcast_task = asyncio.create_task(broadcast_loop())
|
||||
logger.info("WebSocket broadcast task started")
|
||||
|
||||
|
||||
def stop_broadcast_task():
|
||||
"""Stop the background broadcast loop. Call during app shutdown."""
|
||||
global _broadcast_task
|
||||
if _broadcast_task and not _broadcast_task.done():
|
||||
_broadcast_task.cancel()
|
||||
logger.info("WebSocket broadcast task stopped")
|
||||
|
||||
|
||||
@router.websocket("/ws/realtime")
|
||||
async def websocket_realtime(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(default=""),
|
||||
):
|
||||
"""
|
||||
WebSocket endpoint for real-time energy data.
|
||||
|
||||
Connect with: ws://host/api/v1/ws/realtime?token=<jwt_token>
|
||||
|
||||
Messages sent to clients:
|
||||
- type: "realtime_update" - periodic snapshot every 15s
|
||||
- type: "alarm_event" - when a new alarm triggers
|
||||
"""
|
||||
# Authenticate
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing token")
|
||||
return
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
await manager.connect(websocket)
|
||||
|
||||
# Ensure broadcast task is running
|
||||
start_broadcast_task()
|
||||
|
||||
# Send initial data immediately
|
||||
try:
|
||||
initial_data = await get_realtime_snapshot()
|
||||
await websocket.send_json({
|
||||
"type": "realtime_update",
|
||||
"data": initial_data,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending initial data: {e}")
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
try:
|
||||
while True:
|
||||
# Wait for any client message (ping/pong, or just keep alive)
|
||||
data = await websocket.receive_text()
|
||||
# Client can send "ping" to keep alive
|
||||
if data == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
except Exception:
|
||||
manager.disconnect(websocket)
|
||||
Reference in New Issue
Block a user