"""기존 비정규 측점 → 구조물 정본 마이그레이션. 기존 화면은 구조물을 4종(배관·기성막이·대피로·기타)으로 받아 왔고, 서버에는 `chainage_m` + 표시 문자열만 남겼다. 신규 레지스트리 타입으로 옮기면서 종류별 의미를 살린다. - 배관: 옮기지 않는다 — `pipe_points.json`이 정본이다. - 기성막이 → 기슭막이(`revetment`). 기존 이름이 오기였다 (2026-08-16 사용자 확정). - 대피로 → 대피소(`refuge`)로 통합. 폭 값은 대피소 너비 옵션으로 옮긴다 (동 확정). - 그 밖: 이름을 살려 `기타`. 구간형으로 바뀌는 타입(기슭막이·대피소)은 기존 데이터에 종점이 없다. 기점만 알고 있으므로 최소 구간을 임시로 주고, 사용자가 화면에서 종점을 조정한다 — 값을 지어내는 것보다 낫다. """ import re from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, station_planting_labels, structure_type_map, ) PIPE_STRUCTURE_NAME = "배관" # 기존 타입 이름 → 신규 type_id. LEGACY_TYPE_MAP = { "기성막이": "revetment", "대피로": "refuge", "기타": "etc", } # 종점을 모르는 구간형 항목에 주는 임시 길이(m). 화면에서 조정하라는 표시값이다. DEFAULT_INTERVAL_LENGTH_M = 15.0 # 확정 저장분은 표시 라벨 문자열뿐이다(예: "파형강관 D800", "대피로 2.0m"). # 라벨 생성 규칙은 B05_Profile_UI_IrregularStations.ts의 structureLabel()이 정본. _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE) _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) def _managed_elsewhere_labels() -> set[str]: """서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels). """ return station_planting_labels() def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: """다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가.""" label = str(entry.get("structure", "")).strip() return ( entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME or label in _managed_elsewhere_labels() or bool(_PIPE_LABEL.match(label)) ) def _legacy_type_of(entry: dict[str, Any]) -> str | None: """기존 종류 이름을 알아낸다 — structureType이 없으면 라벨 문자열에서 판별한다. 확정 저장 형식(`{chainage_m, structure}`)에는 structureType이 없다(크로스체크 지적 1, 2026-08-16). 라벨은 structureLabel()이 만든 고정 형식이라 되짚을 수 있다. """ explicit = entry.get("structureType") if explicit in LEGACY_TYPE_MAP: return str(explicit) label = str(entry.get("structure", "")).strip() if label == "기성막이": return "기성막이" if _ESCAPE_LABEL.match(label): return "대피로" return None def _escape_width_of(entry: dict[str, Any]) -> float | None: """대피로 폭 — 명시 필드가 우선, 없으면 라벨("대피로 2.0m")에서 파싱한다.""" width = entry.get("escapeWidthM") if width is not None: return float(width) match = _ESCAPE_LABEL.match(str(entry.get("structure", "")).strip()) if match and match.group(1): return float(match.group(1)) return None def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[StructureInstance]: """기존 비정규 측점 목록을 구조물 인스턴스로 옮긴다(같은 입력이면 같은 결과).""" types = structure_type_map() migrated: list[StructureInstance] = [] seen: set[tuple[str, float]] = set() for entry in entries: if _is_managed_elsewhere(entry): continue chainage = float(entry.get("chainage_m", 0.0)) legacy_type = _legacy_type_of(entry) type_id = LEGACY_TYPE_MAP.get(legacy_type or "", "etc") definition = types[type_id] key = (type_id, round(chainage, 3)) if key in seen: continue seen.add(key) data: dict[str, Any] = { "type_id": type_id, "placement": definition.placement, "options": _options_for(type_id, entry), "placement_source": "manual", } if definition.placement == "interval": data["start_m"] = chainage data["end_m"] = chainage + DEFAULT_INTERVAL_LENGTH_M data["memo"] = "구 비정규 측점 이관 — 종점 확인 필요" else: data["chainage_m"] = chainage migrated.append(StructureInstance.model_validate(data)) return migrated def _options_for(type_id: str, entry: dict[str, Any]) -> dict[str, Any]: if type_id == "refuge": width = _escape_width_of(entry) return {"width_m": width} if width is not None else {} if type_id == "etc": name = entry.get("customName") or entry.get("structure") or "기타 구조물" return {"name": str(name).strip()} return {}