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:
Du Wenbo
2026-04-04 18:14:11 +08:00
commit 92ec910a13
227 changed files with 39179 additions and 0 deletions

View File

View 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)

View 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,
}

View 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}

View 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,
}

View 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", {}),
}

View 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)

View 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,
}

View 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
View 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)

View 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()]

View 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,
}

View 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,
}

View 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
],
}

View 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,
}

View 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,
}

View 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},
]
}

View 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
View 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,
}

View 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)

View 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": "设置已更新"}

View 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()]

View 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))

View 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)