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
+178
View File
@@ -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<HTMLDivElement | null>(null);
const [lineForm] = Form.useForm<LineFormValues>();
const [towerForm] = Form.useForm<TowerFormValues>();
const [towerProfileForm] = Form.useForm<TowerProfileFormValues>();
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<LineSummary | null>(null);
const [editingTower, setEditingTower] = useState<LineTowerSummary | null>(null);
const [editingTowerProfileTower, setEditingTowerProfileTower] = useState<LineTowerSummary | null>(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() {
</Button>
)}
{canTowerManage && (
<Button size="small" onClick={() => openTowerProfileModal(row)}>
</Button>
)}
{canTowerManage && (
<Popconfirm
title="删除杆塔"
@@ -1199,6 +1313,70 @@ export default function AdminPowerLinesPage() {
</div>
</Form>
</Modal>
<Modal
title={editingTowerProfileTower ? `专业参数 - ${editingTowerProfileTower.tower_no}` : "专业参数"}
open={towerProfileModalOpen}
width={920}
okText="保存"
confirmLoading={saveTowerProfileMutation.isPending}
onCancel={() => {
if (saveTowerProfileMutation.isPending) return;
setTowerProfileModalOpen(false);
setEditingTowerProfileTower(null);
}}
onOk={async () => {
const values = await towerProfileForm.validateFields();
saveTowerProfileMutation.mutate(values);
}}
>
<Form<TowerProfileFormValues> form={towerProfileForm} layout="vertical" initialValues={EMPTY_TOWER_PROFILE_FORM}>
<div className="grid gap-3 md:grid-cols-2">
<Form.Item name="structure_kind" label="直线/耐张">
<Input />
</Form.Item>
<Form.Item name="stroke_mode" label="绕击/反击模式">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_1" label="I回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_2" label="II回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_3" label="III回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_4" label="IV回相序">
<Input />
</Form.Item>
<Form.Item name="arrester_a" label="A相避雷器">
<Input />
</Form.Item>
<Form.Item name="arrester_b" label="B相避雷器">
<Input />
</Form.Item>
<Form.Item name="arrester_c" label="C相避雷器">
<Input />
</Form.Item>
</div>
<Form.Item
name="geometry_layers_json"
label="回路几何 JSON"
rules={[{
validator: async (_, value) => {
if (!value || !String(value).trim()) {
return;
}
JSON.parse(String(value));
},
message: "请输入合法 JSON",
}]}
>
<Input.TextArea rows={12} spellCheck={false} />
</Form.Item>
</Form>
</Modal>
</>
);
}
+37
View File
@@ -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<string, unknown>;
extra_profile_json: Record<string, unknown>;
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";