From 98f97bec01ad3ac5de54fcfb6153ffe92c2e8299 Mon Sep 17 00:00:00 2001 From: chengkml <45121067+chengkml@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:16:09 +0800 Subject: [PATCH] feat: migrate tower profile professional fields --- api/app/api/router.py | 2 + api/app/api/v1/tower_profiles.py | 36 +++++ api/app/core/database.py | 36 +++++ api/app/models/tower_profile.py | 2 + api/app/schemas/tower_profile.py | 4 + api/app/services/line_service.py | 60 ++++++++ api/app/services/tower_profile_service.py | 4 + api/tests/test_tower_profile_migration.py | 18 +++ web/src/app/admin/power-lines/page.tsx | 178 ++++++++++++++++++++++ web/src/types/auth.ts | 37 +++++ 10 files changed, 377 insertions(+) create mode 100644 api/app/api/v1/tower_profiles.py create mode 100644 api/tests/test_tower_profile_migration.py diff --git a/api/app/api/router.py b/api/app/api/router.py index 8d51448..1f58a56 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -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) diff --git a/api/app/api/v1/tower_profiles.py b/api/app/api/v1/tower_profiles.py new file mode 100644 index 0000000..043f176 --- /dev/null +++ b/api/app/api/v1/tower_profiles.py @@ -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 \ No newline at end of file diff --git a/api/app/core/database.py b/api/app/core/database.py index a62b23f..34fba18 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -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"} diff --git a/api/app/models/tower_profile.py b/api/app/models/tower_profile.py index 71e4b6b..f55f93d 100644 --- a/api/app/models/tower_profile.py +++ b/api/app/models/tower_profile.py @@ -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) diff --git a/api/app/schemas/tower_profile.py b/api/app/schemas/tower_profile.py index 2860dc3..29c3bd3 100644 --- a/api/app/schemas/tower_profile.py +++ b/api/app/schemas/tower_profile.py @@ -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 diff --git a/api/app/services/line_service.py b/api/app/services/line_service.py index 9051633..63b69e7 100644 --- a/api/app/services/line_service.py +++ b/api/app/services/line_service.py @@ -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: diff --git a/api/app/services/tower_profile_service.py b/api/app/services/tower_profile_service.py index 73bc18e..d646583 100644 --- a/api/app/services/tower_profile_service.py +++ b/api/app/services/tower_profile_service.py @@ -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 diff --git a/api/tests/test_tower_profile_migration.py b/api/tests/test_tower_profile_migration.py new file mode 100644 index 0000000..d1eb927 --- /dev/null +++ b/api/tests/test_tower_profile_migration.py @@ -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 \ No newline at end of file diff --git a/web/src/app/admin/power-lines/page.tsx b/web/src/app/admin/power-lines/page.tsx index a04aadc..5509a76 100644 --- a/web/src/app/admin/power-lines/page.tsx +++ b/web/src/app/admin/power-lines/page.tsx @@ -36,6 +36,7 @@ import type { LineTowerListResponse, LineTowerSummary, TowerModelSummary, + TowerProfileDetail, } from "@/types/auth"; type LineFormValues = { @@ -64,6 +65,19 @@ type TowerFormValues = { risk_level: string; }; +type TowerProfileFormValues = { + structure_kind: string; + stroke_mode: string; + phase_sequence_1: string; + phase_sequence_2: string; + phase_sequence_3: string; + phase_sequence_4: string; + arrester_a: string; + arrester_b: string; + arrester_c: string; + geometry_layers_json: string; +}; + const STATUS_OPTIONS = [ { value: "all", label: "全部状态" }, { value: "enabled", label: "启用" }, @@ -165,6 +179,19 @@ const EMPTY_TOWER_FORM: TowerFormValues = { risk_level: "", }; +const EMPTY_TOWER_PROFILE_FORM: TowerProfileFormValues = { + structure_kind: "", + stroke_mode: "", + phase_sequence_1: "", + phase_sequence_2: "", + phase_sequence_3: "", + phase_sequence_4: "", + arrester_a: "", + arrester_b: "", + arrester_c: "", + geometry_layers_json: "{}", +}; + function formatStatus(status: string): string { if (status === "enabled") return "启用"; if (status === "disabled") return "禁用"; @@ -186,6 +213,7 @@ export default function AdminPowerLinesPage() { const panelScrollAnchorRef = useRef(null); const [lineForm] = Form.useForm(); const [towerForm] = Form.useForm(); + const [towerProfileForm] = Form.useForm(); const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState<(typeof STATUS_OPTIONS)[number]["value"]>("all"); @@ -197,8 +225,10 @@ export default function AdminPowerLinesPage() { const [towerPagination, setTowerPagination] = useState({ current: 1, pageSize: TOWER_TABLE_DEFAULT_PAGE_SIZE }); const [lineModalOpen, setLineModalOpen] = useState(false); const [towerModalOpen, setTowerModalOpen] = useState(false); + const [towerProfileModalOpen, setTowerProfileModalOpen] = useState(false); const [editingLine, setEditingLine] = useState(null); const [editingTower, setEditingTower] = useState(null); + const [editingTowerProfileTower, setEditingTowerProfileTower] = useState(null); const [towerViewMode, setTowerViewMode] = useState<"table" | "map">("map"); const [error, setError] = useState(""); const [panelBodyHeight, setPanelBodyHeight] = useState(POWER_LINES_PANEL_MIN_HEIGHT); @@ -293,6 +323,18 @@ export default function AdminPowerLinesPage() { }, }); + const towerProfileQuery = useQuery({ + queryKey: ["tower-profile", editingTowerProfileTower?.id], + enabled: !!user && !!editingTowerProfileTower && towerProfileModalOpen && canTowerRead, + queryFn: async () => { + const response = await fetchWithAuth(`/api/v1/tower-profiles/${editingTowerProfileTower?.id}`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as TowerProfileDetail; + }, + }); + const refreshLines = useCallback(async () => { await queryClient.invalidateQueries({ predicate: (query) => @@ -311,6 +353,25 @@ export default function AdminPowerLinesPage() { }); }, [queryClient]); + useEffect(() => { + const profile = towerProfileQuery.data; + if (!profile) { + return; + } + towerProfileForm.setFieldsValue({ + structure_kind: profile.structure_kind ?? "", + stroke_mode: profile.stroke_mode ?? "", + phase_sequence_1: profile.phase_sequence_1 ?? "", + phase_sequence_2: profile.phase_sequence_2 ?? "", + phase_sequence_3: profile.phase_sequence_3 ?? "", + phase_sequence_4: profile.phase_sequence_4 ?? "", + arrester_a: profile.arrester_a ?? "", + arrester_b: profile.arrester_b ?? "", + arrester_c: profile.arrester_c ?? "", + geometry_layers_json: JSON.stringify(profile.geometry_layers_json ?? {}, null, 2), + }); + }, [towerProfileForm, towerProfileQuery.data]); + useTopicSubscription("admin.power-lines", useCallback(() => { void refreshLines(); void refreshTowers(); @@ -531,6 +592,48 @@ export default function AdminPowerLinesPage() { }, }); + const saveTowerProfileMutation = useMutation({ + mutationFn: async (values: TowerProfileFormValues) => { + if (!editingTowerProfileTower) { + throw new Error("未选择杆塔"); + } + const geometryLayers = values.geometry_layers_json.trim() + ? JSON.parse(values.geometry_layers_json) + : {}; + const response = await fetchWithAuth(`/api/v1/tower-profiles/${editingTowerProfileTower.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + structure_kind: values.structure_kind.trim() || null, + stroke_mode: values.stroke_mode.trim() || null, + phase_sequence_1: values.phase_sequence_1.trim() || null, + phase_sequence_2: values.phase_sequence_2.trim() || null, + phase_sequence_3: values.phase_sequence_3.trim() || null, + phase_sequence_4: values.phase_sequence_4.trim() || null, + arrester_a: values.arrester_a.trim() || null, + arrester_b: values.arrester_b.trim() || null, + arrester_c: values.arrester_c.trim() || null, + geometry_layers_json: geometryLayers, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as TowerProfileDetail; + }, + onSuccess: async () => { + setError(""); + messageApi.success("专业参数已保存"); + setTowerProfileModalOpen(false); + setEditingTowerProfileTower(null); + towerProfileForm.resetFields(); + await queryClient.invalidateQueries({ queryKey: ["tower-profile"] }); + }, + onError: (candidate) => { + setError(candidate instanceof Error ? candidate.message : "保存专业参数失败"); + }, + }); + const importMutation = useMutation({ mutationFn: async (file: File) => { if (!effectiveSelectedLineId) { @@ -646,6 +749,12 @@ export default function AdminPowerLinesPage() { setTowerModalOpen(true); }; + const openTowerProfileModal = (item: LineTowerSummary) => { + setEditingTowerProfileTower(item); + towerProfileForm.setFieldsValue(EMPTY_TOWER_PROFILE_FORM); + setTowerProfileModalOpen(true); + }; + const lineCards = lines.map((line) => { const selected = line.id === effectiveSelectedLineId; return ( @@ -757,6 +866,11 @@ export default function AdminPowerLinesPage() { 编辑 )} + {canTowerManage && ( + + )} {canTowerManage && ( + + { + if (saveTowerProfileMutation.isPending) return; + setTowerProfileModalOpen(false); + setEditingTowerProfileTower(null); + }} + onOk={async () => { + const values = await towerProfileForm.validateFields(); + saveTowerProfileMutation.mutate(values); + }} + > + form={towerProfileForm} layout="vertical" initialValues={EMPTY_TOWER_PROFILE_FORM}> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ { + if (!value || !String(value).trim()) { + return; + } + JSON.parse(String(value)); + }, + message: "请输入合法 JSON", + }]} + > + + + +
); } diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 1ce3d23..42de8fc 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -578,6 +578,43 @@ export type LineTowerImportResponse = { warnings: string[]; }; +export type TowerProfileDetail = { + id: string | null; + tower_id: string; + line_id: string; + tower_no: string; + seq_no: number; + tower_model: string | null; + tower_type: string | null; + profile_exists: boolean; + phase_sequence_1: string | null; + phase_sequence_2: string | null; + phase_sequence_3: string | null; + phase_sequence_4: string | null; + arrester_a: string | null; + arrester_b: string | null; + arrester_c: string | null; + protection_angle_left_deg: number | null; + protection_angle_right_deg: number | null; + shield_wire_height_m: number | null; + insulator_length_m: number | null; + call_height_m: number | null; + angle_deg: number | null; + current_a: number | null; + current_b: number | null; + structure_kind: string | null; + stroke_mode: string | null; + current_type: string | null; + current_head_time_us: number | null; + current_tail_time_us: number | null; + geometry_layers_json: Record; + extra_profile_json: Record; + create_date: string | null; + create_user: string | null; + update_date: string | null; + update_user: string | null; +}; + export type AtpModelStatus = "enabled" | "disabled"; export type AtpModelSourceType = "atpdraw" | "atp" | "manual"; export type AtpModelVersionStatus = "draft" | "released" | "archived";