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:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi, meters
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -28,3 +28,4 @@ api_router.include_router(ai_ops.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(version.router)
|
||||
api_router.include_router(kpi.router)
|
||||
api_router.include_router(meters.router)
|
||||
|
||||
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]
|
||||
@@ -20,6 +20,7 @@ from app.models.prediction import PredictionTask, PredictionResult, Optimization
|
||||
from app.models.energy_strategy import TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport
|
||||
from app.models.weather import WeatherData, WeatherConfig
|
||||
from app.models.ai_ops import DeviceHealthScore, AnomalyDetection, DiagnosticReport, MaintenancePrediction, OpsInsight
|
||||
from app.models.meter import MeterReading
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "AuditLog",
|
||||
@@ -40,4 +41,5 @@ __all__ = [
|
||||
"TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport",
|
||||
"WeatherData", "WeatherConfig",
|
||||
"DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight",
|
||||
"MeterReading",
|
||||
]
|
||||
|
||||
24
backend/app/models/meter.py
Normal file
24
backend/app/models/meter.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class MeterReading(Base):
|
||||
"""电表读数时序数据"""
|
||||
__tablename__ = "meter_readings"
|
||||
|
||||
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
||||
meter_id = Column(Integer, primary_key=True, nullable=False)
|
||||
meter_name = Column(String(100))
|
||||
forward_active_energy = Column(Float) # 正向有功电能 kWh
|
||||
reverse_active_energy = Column(Float) # 反向有功电能 kWh
|
||||
active_power = Column(Float) # 有功功率 kW
|
||||
reactive_power = Column(Float) # 无功功率 kvar
|
||||
power_factor = Column(Float) # 功率因数
|
||||
voltage_a = Column(Float) # A相电压 V
|
||||
voltage_b = Column(Float) # B相电压 V
|
||||
voltage_c = Column(Float) # C相电压 V
|
||||
current_a = Column(Float) # A相电流 A
|
||||
current_b = Column(Float) # B相电流 A
|
||||
current_c = Column(Float) # C相电流 A
|
||||
raw_json = Column(JSON) # 原始数据包
|
||||
Reference in New Issue
Block a user