feat: migrate tower profile professional fields
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user