feat: migrate tower profile professional fields

This commit is contained in:
chengkml
2026-06-06 22:16:09 +08:00
parent 578d124607
commit 98f97bec01
10 changed files with 377 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ from .v1.question_bank import router as question_bank_router
from .v1.system_params import router as system_params_router
from .v1.task_monitor import router as task_monitor_router
from .v1.tower_models import router as tower_models_router
from .v1.tower_profiles import router as tower_profiles_router
from .v1.users import router as users_router
from .v1.wine import router as wine_router
from .v1.ws import router as ws_router
@@ -31,6 +32,7 @@ v1_router.include_router(flower_monitor_router)
v1_router.include_router(lightning_router)
v1_router.include_router(lines_router)
v1_router.include_router(tower_models_router)
v1_router.include_router(tower_profiles_router)
v1_router.include_router(question_bank_router)
v1_router.include_router(wine_router)
v1_router.include_router(ws_router)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...schemas.tower_profile import TowerProfileDetail, TowerProfileUpsertRequest
from ...services.tower_profile_service import get_tower_profile_detail, upsert_tower_profile
router = APIRouter(prefix="/tower-profiles", tags=["tower-profiles"])
@router.get("/{tower_id}", response_model=TowerProfileDetail)
def get_tower_profile_endpoint(
tower_id: str,
_: CurrentUser = Depends(require_any_permission("tower.read", "tower.manage", "line.read", "line.manage")),
db: Session = Depends(get_db),
) -> TowerProfileDetail:
item = get_tower_profile_detail(db, tower_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tower not found")
return item
@router.put("/{tower_id}", response_model=TowerProfileDetail)
def put_tower_profile_endpoint(
tower_id: str,
payload: TowerProfileUpsertRequest,
current_user: CurrentUser = Depends(require_permission("tower.manage")),
db: Session = Depends(get_db),
) -> TowerProfileDetail:
item = upsert_tower_profile(db, tower_id, payload, actor=current_user.user)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tower not found")
return item
+36
View File
@@ -332,6 +332,41 @@ def _ensure_tower_model_column_compatibility() -> None:
)
def _ensure_tower_profile_column_compatibility() -> None:
"""
Keep `tower_profile` columns aligned with the current ORM mapping.
"""
if not database_url.startswith("postgresql"):
return
schema = settings.resolved_db_schema
with engine.begin() as connection:
db_inspector = inspect(connection)
if not db_inspector.has_table("tower_profile", schema=schema):
return
column_names = {
column["name"]
for column in db_inspector.get_columns("tower_profile", schema=schema)
}
if "structure_kind" not in column_names:
connection.execute(
text("ALTER TABLE tower_profile ADD COLUMN IF NOT EXISTS structure_kind VARCHAR(64)"),
)
logger.warning(
"Detected missing tower_profile.structure_kind; added nullable structure kind column.",
)
if "stroke_mode" not in column_names:
connection.execute(
text("ALTER TABLE tower_profile ADD COLUMN IF NOT EXISTS stroke_mode VARCHAR(32)"),
)
logger.warning(
"Detected missing tower_profile.stroke_mode; added nullable stroke mode column.",
)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
@@ -375,6 +410,7 @@ def init_db() -> None:
_ensure_user_audit_column_compatibility()
_ensure_elevation_dataset_column_compatibility()
_ensure_tower_model_column_compatibility()
_ensure_tower_profile_column_compatibility()
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
local_hosts = {"db", "localhost", "127.0.0.1", "::1"}
+2
View File
@@ -48,6 +48,8 @@ class TowerProfile(Base):
angle_deg: Mapped[float | None] = mapped_column(Float)
current_a: Mapped[float | None] = mapped_column(Float)
current_b: Mapped[float | None] = mapped_column(Float)
structure_kind: Mapped[str | None] = mapped_column(String(64), index=True)
stroke_mode: Mapped[str | None] = mapped_column(String(32), index=True)
current_type: Mapped[str | None] = mapped_column(String(32), index=True)
current_head_time_us: Mapped[float | None] = mapped_column(Float)
current_tail_time_us: Mapped[float | None] = mapped_column(Float)
+4
View File
@@ -30,6 +30,8 @@ class TowerProfileDetail(BaseModel):
angle_deg: float | None = None
current_a: float | None = None
current_b: float | None = None
structure_kind: str | None = None
stroke_mode: str | None = None
current_type: str | None = None
current_head_time_us: float | None = None
current_tail_time_us: float | None = None
@@ -57,6 +59,8 @@ class TowerProfileUpsertRequest(BaseModel):
angle_deg: float | None = None
current_a: float | None = None
current_b: float | None = None
structure_kind: str | None = Field(default=None, max_length=64)
stroke_mode: str | None = Field(default=None, max_length=32)
current_type: str | None = Field(default=None, max_length=32)
current_head_time_us: float | None = None
current_tail_time_us: float | None = None
+60
View File
@@ -15,6 +15,7 @@ from ..models.base import utcnow
from ..models.line import Line
from ..models.line_tower import LineTower
from ..models.tower_model import TowerModel
from ..models.tower_profile import TowerProfile
from ..schemas.line import (
LineCreateRequest,
LineListResponse,
@@ -506,6 +507,13 @@ def import_line_towers_from_csv(
tower.raw_extra_json = _extract_extra_values(row, extra_headers)
tower.update_user = actor_user_id
tower.update_date = utcnow()
db.flush()
_upsert_tower_profile_from_legacy_row(
db,
tower=tower,
row=row,
actor_user_id=actor_user_id,
)
tower_by_seq[tower.seq_no] = tower
tower_by_no[tower.tower_no] = tower
@@ -744,6 +752,58 @@ def _build_lightning_result(row: dict[str, str]) -> dict[str, Any]:
}
def _upsert_tower_profile_from_legacy_row(
db: Session,
*,
tower: LineTower,
row: dict[str, str],
actor_user_id: str,
) -> None:
profile = db.execute(select(TowerProfile).where(TowerProfile.tower_id == tower.id)).scalar_one_or_none()
now = utcnow()
if profile is None:
profile = TowerProfile(
tower_id=tower.id,
create_date=now,
create_user=actor_user_id,
update_date=now,
update_user=actor_user_id,
)
db.add(profile)
geometry_layers = _build_circuit_geometry(row)
extra_profile_json = dict(profile.extra_profile_json or {})
profile.phase_sequence_1 = _pick_optional_value(_normalize_str(row.get("I回相序")), profile.phase_sequence_1)
profile.phase_sequence_2 = _pick_optional_value(_normalize_str(row.get("II回相序")), profile.phase_sequence_2)
profile.phase_sequence_3 = _pick_optional_value(_normalize_str(row.get("III回相序")), profile.phase_sequence_3)
profile.phase_sequence_4 = _pick_optional_value(_normalize_str(row.get("IV回相序")), profile.phase_sequence_4)
profile.arrester_a = _pick_optional_value(_normalize_str(row.get("A相是否安装避雷器")), profile.arrester_a)
profile.arrester_b = _pick_optional_value(_normalize_str(row.get("B相是否安装避雷器")), profile.arrester_b)
profile.arrester_c = _pick_optional_value(_normalize_str(row.get("C相是否安装避雷器")), profile.arrester_c)
profile.protection_angle_left_deg = _pick_optional_value(_parse_float(row.get("左避雷中距m")), profile.protection_angle_left_deg)
profile.protection_angle_right_deg = _pick_optional_value(_parse_float(row.get("右避雷中距m")), profile.protection_angle_right_deg)
profile.shield_wire_height_m = _pick_optional_value(_parse_float(row.get("避雷线高度m")), profile.shield_wire_height_m)
profile.insulator_length_m = _pick_optional_value(_parse_float(row.get("绝缘子串长度mm")), profile.insulator_length_m)
profile.call_height_m = _pick_optional_value(_parse_float(row.get("杆塔呼高m")), profile.call_height_m)
profile.angle_deg = _pick_optional_value(_parse_float(row.get("电角度")), profile.angle_deg)
profile.current_a = _pick_optional_value(_parse_float(row.get("雷电流幅值a")), profile.current_a)
profile.current_b = _pick_optional_value(_parse_float(row.get("雷电流幅值b")), profile.current_b)
profile.structure_kind = _pick_optional_value(_normalize_str(row.get("直线或耐张杆塔")), profile.structure_kind)
profile.stroke_mode = _pick_optional_value(_normalize_str(row.get("绕击反击")), profile.stroke_mode)
profile.geometry_layers_json = _pick_dict_value(geometry_layers, profile.geometry_layers_json or {})
cause_analysis = _normalize_str(row.get("原因分析"))
mitigation_recommendation = _normalize_str(row.get("措施推荐"))
if cause_analysis is not None:
extra_profile_json["cause_analysis"] = cause_analysis
if mitigation_recommendation is not None:
extra_profile_json["mitigation_recommendation"] = mitigation_recommendation
profile.extra_profile_json = extra_profile_json
profile.update_date = now
profile.update_user = actor_user_id
def _extract_extra_values(row: dict[str, str], extra_headers: list[str]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in extra_headers:
@@ -43,6 +43,8 @@ def serialize_tower_profile(tower: LineTower, profile: TowerProfile | None) -> T
angle_deg=profile.angle_deg if profile else None,
current_a=profile.current_a if profile else None,
current_b=profile.current_b if profile else None,
structure_kind=profile.structure_kind if profile else None,
stroke_mode=profile.stroke_mode if profile else None,
current_type=profile.current_type if profile else None,
current_head_time_us=profile.current_head_time_us if profile else None,
current_tail_time_us=profile.current_tail_time_us if profile else None,
@@ -101,6 +103,8 @@ def upsert_tower_profile(
profile.angle_deg = payload.angle_deg
profile.current_a = payload.current_a
profile.current_b = payload.current_b
profile.structure_kind = payload.structure_kind
profile.stroke_mode = payload.stroke_mode
profile.current_type = payload.current_type
profile.current_head_time_us = payload.current_head_time_us
profile.current_tail_time_us = payload.current_tail_time_us
+18
View File
@@ -0,0 +1,18 @@
from app.schemas.tower_profile import TowerProfileUpsertRequest
def test_tower_profile_upsert_request_accepts_new_professional_fields() -> None:
payload = TowerProfileUpsertRequest(
structure_kind="直线杆塔",
stroke_mode="反击",
geometry_layers_json={
"I": {
"phase_spacing_m": {"upper": 5.1, "middle": 4.2, "lower": 3.3},
"phase_height_m": {"upper": 25.0, "middle": 22.0, "lower": 19.0},
}
},
)
assert payload.structure_kind == "直线杆塔"
assert payload.stroke_mode == "反击"
assert payload.geometry_layers_json["I"]["phase_spacing_m"]["upper"] == 5.1