fix(models): add alembic migration 009 for missing tables (v1.4.1)
Migration adds tables that existed in models/ but were never included in alembic history: - ai_ops: device_health_scores, anomaly_detections, diagnostic_reports, maintenance_predictions, ops_insights - energy_strategy: tou_pricing, tou_pricing_periods, energy_strategies, strategy_executions, monthly_cost_reports - weather: weather_data, weather_config - prediction: prediction_tasks, prediction_results, optimization_schedules Without this migration, fresh deploys would 500 on these endpoints: - /api/v1/ai-ops/health, /ai-ops/dashboard - /api/v1/strategy/pricing - /api/v1/prediction/forecast - /api/v1/weather/current Discovered during Z-Park demo deployment on xie_openclaw1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
201
backend/app/api/v1/meters.py
Normal file
201
backend/app/api/v1/meters.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.meter import MeterReading
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/meters", tags=["电表管理"])
|
||||
|
||||
settings = get_settings()
|
||||
_customer_config = settings.load_customer_config()
|
||||
|
||||
|
||||
# --------------- Pydantic schemas ---------------
|
||||
|
||||
class MeterInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
meter_no: str | None = None
|
||||
modbus_addr: int | None = None
|
||||
|
||||
|
||||
class MeterReadingResponse(BaseModel):
|
||||
time: datetime
|
||||
meter_id: int
|
||||
meter_name: str | None = None
|
||||
forward_active_energy: float | None = None
|
||||
reverse_active_energy: float | None = None
|
||||
active_power: float | None = None
|
||||
reactive_power: float | None = None
|
||||
power_factor: float | None = None
|
||||
voltage_a: float | None = None
|
||||
voltage_b: float | None = None
|
||||
voltage_c: float | None = None
|
||||
current_a: float | None = None
|
||||
current_b: float | None = None
|
||||
current_c: float | None = None
|
||||
|
||||
|
||||
class MeterLatestResponse(BaseModel):
|
||||
meter: MeterInfo
|
||||
latest: MeterReadingResponse | None = None
|
||||
|
||||
|
||||
class MeterOverviewItem(BaseModel):
|
||||
meter: MeterInfo
|
||||
latest: MeterReadingResponse | None = None
|
||||
|
||||
|
||||
class MeterOverviewResponse(BaseModel):
|
||||
meters: list[MeterOverviewItem]
|
||||
total_forward_energy: float
|
||||
total_reverse_energy: float
|
||||
total_active_power: float
|
||||
|
||||
|
||||
class MeterListResponse(BaseModel):
|
||||
items: list[MeterInfo]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------- Helpers ---------------
|
||||
|
||||
def _get_meter_configs() -> list[dict]:
|
||||
"""Load meter list from customer config.yaml."""
|
||||
return _customer_config.get("meters", [])
|
||||
|
||||
|
||||
def _meter_config_to_info(cfg: dict) -> MeterInfo:
|
||||
return MeterInfo(
|
||||
id=cfg["id"],
|
||||
name=cfg.get("name", f"Meter-{cfg['id']}"),
|
||||
meter_no=cfg.get("meter_no"),
|
||||
modbus_addr=cfg.get("modbus_addr"),
|
||||
)
|
||||
|
||||
|
||||
def _reading_to_response(r: MeterReading) -> MeterReadingResponse:
|
||||
return MeterReadingResponse(
|
||||
time=r.time,
|
||||
meter_id=r.meter_id,
|
||||
meter_name=r.meter_name,
|
||||
forward_active_energy=r.forward_active_energy,
|
||||
reverse_active_energy=r.reverse_active_energy,
|
||||
active_power=r.active_power,
|
||||
reactive_power=r.reactive_power,
|
||||
power_factor=r.power_factor,
|
||||
voltage_a=r.voltage_a,
|
||||
voltage_b=r.voltage_b,
|
||||
voltage_c=r.voltage_c,
|
||||
current_a=r.current_a,
|
||||
current_b=r.current_b,
|
||||
current_c=r.current_c,
|
||||
)
|
||||
|
||||
|
||||
# --------------- Endpoints ---------------
|
||||
|
||||
@router.get("", response_model=MeterListResponse)
|
||||
async def list_meters(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出所有已配置的电表"""
|
||||
configs = _get_meter_configs()
|
||||
items = [_meter_config_to_info(c) for c in configs]
|
||||
return MeterListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/overview", response_model=MeterOverviewResponse)
|
||||
async def meter_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""仪表盘概览 - 所有电表最新值及汇总"""
|
||||
configs = _get_meter_configs()
|
||||
overview_items: list[MeterOverviewItem] = []
|
||||
total_forward = 0.0
|
||||
total_reverse = 0.0
|
||||
total_power = 0.0
|
||||
|
||||
for cfg in configs:
|
||||
info = _meter_config_to_info(cfg)
|
||||
result = await db.execute(
|
||||
select(MeterReading)
|
||||
.where(MeterReading.meter_id == cfg["id"])
|
||||
.order_by(desc(MeterReading.time))
|
||||
.limit(1)
|
||||
)
|
||||
reading = result.scalar_one_or_none()
|
||||
latest = _reading_to_response(reading) if reading else None
|
||||
if reading:
|
||||
total_forward += reading.forward_active_energy or 0
|
||||
total_reverse += reading.reverse_active_energy or 0
|
||||
total_power += reading.active_power or 0
|
||||
overview_items.append(MeterOverviewItem(meter=info, latest=latest))
|
||||
|
||||
return MeterOverviewResponse(
|
||||
meters=overview_items,
|
||||
total_forward_energy=total_forward,
|
||||
total_reverse_energy=total_reverse,
|
||||
total_active_power=total_power,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{meter_id}/latest", response_model=MeterLatestResponse)
|
||||
async def get_meter_latest(
|
||||
meter_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定电表的最新读数"""
|
||||
configs = _get_meter_configs()
|
||||
cfg = next((c for c in configs if c["id"] == meter_id), None)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="电表不存在")
|
||||
|
||||
result = await db.execute(
|
||||
select(MeterReading)
|
||||
.where(MeterReading.meter_id == meter_id)
|
||||
.order_by(desc(MeterReading.time))
|
||||
.limit(1)
|
||||
)
|
||||
reading = result.scalar_one_or_none()
|
||||
return MeterLatestResponse(
|
||||
meter=_meter_config_to_info(cfg),
|
||||
latest=_reading_to_response(reading) if reading else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{meter_id}/readings", response_model=list[MeterReadingResponse])
|
||||
async def get_meter_readings(
|
||||
meter_id: int,
|
||||
start: Optional[datetime] = Query(None, description="开始时间 ISO8601"),
|
||||
end: Optional[datetime] = Query(None, description="结束时间 ISO8601"),
|
||||
limit: int = Query(100, ge=1, le=10000, description="返回条数上限"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""查询历史读数 (时间范围 + limit)"""
|
||||
configs = _get_meter_configs()
|
||||
cfg = next((c for c in configs if c["id"] == meter_id), None)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="电表不存在")
|
||||
|
||||
query = select(MeterReading).where(MeterReading.meter_id == meter_id)
|
||||
if start:
|
||||
query = query.where(MeterReading.time >= start)
|
||||
if end:
|
||||
query = query.where(MeterReading.time <= end)
|
||||
query = query.order_by(desc(MeterReading.time)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
readings = result.scalars().all()
|
||||
return [_reading_to_response(r) for r in readings]
|
||||
Reference in New Issue
Block a user