Files
Aislo/B05_Profile/B05_Profile_Structures_Migration.py
T
eomsangdonandClaude Fable 5 1c95290a18 fix(B05): 레거시 기성막이 측점을 관 지점 정본으로 이관
기슭막이가 관 지점 시설로 옮겨간 뒤(f916aa52) 이관 경로만 옛 스펙에 남아
`POST /route/structures/migrate` 가 400 으로 통째 실패했음 — 저장소가
`managed_by` 타입을 거절하는데 이관은 계속 구조물로 만들었기 때문. 같은 요청에
실린 대피소·기타까지 하나도 이관되지 않았음. 호출부가 B05 진입 경로라 레거시
프로젝트는 진입할 때마다 실패했음.

- B05_Profile_Structures_Migration: 결과를 MigrationPlan(구조물 / 관 시설)으로 나눔.
  managed_by 타입은 레지스트리 기본값(사용자 확정분)을 승계한 PipePoint 로 만듦 —
  구간은 기준측점 ∓ 전/후
- B05_Profile_Structures_Router: 관 시설분을 pipe_points.json 에 덧붙임. 관 지점
  파일이 없으면 그 몫만 미루고 이력도 남기지 않음(원천 측점이 남아 재이관됨)
- common_util_drainage_pipes: read_pipe_points_file · append_pipe_points_file ·
  pipe_points_path_in 신설 — 저장 당시 노선 지문 보존
- 관 시설에서 투영된 "기슭막이" 라벨이 되이관돼 "기타" 로 굳던 경로도 함께 막음
- B06_Section_Engine_Revetment 삭제 — 호출자 0건(벽은 관 세트가 만듦)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:18:33 +09:00

190 lines
7.9 KiB
Python

"""기존 비정규 측점 → 구조물 정본 마이그레이션.
기존 화면은 구조물을 4종(배관·기성막이·대피로·기타)으로 받아 왔고, 서버에는 `chainage_m` +
표시 문자열만 남겼다. 신규 레지스트리 타입으로 옮기면서 종류별 의미를 살린다.
- 배관: 옮기지 않는다 — `pipe_points.json`이 정본이다.
- 기성막이 → 기슭막이(`revetment`). 기존 이름이 오기였다 (2026-08-16 사용자 확정).
기슭막이는 2026-08-28 이관으로 관 지점 시설이 됐다 — 구조물 목록이 아니라
`pipe_points.json`으로 간다 (2026-09-01 사용자 확정, 건너뛰기는 데이터 유실).
- 대피로 → 대피소(`refuge`)로 통합. 폭 값은 대피소 너비 옵션으로 옮긴다 (동 확정).
- 그 밖: 이름을 살려 `기타`.
구간형으로 바뀌는 타입(대피소)은 기존 데이터에 종점이 없다. 기점만 알고 있으므로 최소
구간을 임시로 주고, 사용자가 화면에서 종점을 조정한다 — 값을 지어내는 것보다 낫다.
관 시설로 가는 기슭막이는 레지스트리 기본값(사용자 확정분)을 승계한다.
"""
import re
from dataclasses import dataclass, field
from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
station_planting_labels,
structure_type_map,
)
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
PIPE_STRUCTURE_NAME = "배관"
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`).
PIPE_POINTS_OWNER = "pipe_points"
@dataclass
class MigrationPlan:
"""이관 결과 — 정본이 갈리므로 목록도 갈린다(2026-09-01 사용자 확정)."""
structures: list[StructureInstance] = field(default_factory=list)
pipe_facilities: list[PipePoint] = field(default_factory=list)
# 기존 타입 이름 → 신규 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).
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관).
"""
owned = {
item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER
}
return station_planting_labels() | owned
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]]) -> MigrationPlan:
"""기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과).
타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 —
기슭막이가 관 지점 시설로 옮겨간 뒤(2026-08-28) 구조물로 저장하면 저장소가 거절한다
(2026-09-01 사용자 확정: 건너뛰지 않고 관 지점으로 옮긴다).
"""
types = structure_type_map()
structures: list[StructureInstance] = []
facilities: list[PipePoint] = []
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)
if definition.managed_by == PIPE_POINTS_OWNER:
facilities.append(_pipe_facility_for(definition, chainage))
continue
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
structures.append(StructureInstance.model_validate(data))
return MigrationPlan(structures=structures, pipe_facilities=facilities)
def _pipe_facility_for(definition, chainage: float) -> PipePoint:
"""관 지점 정본이 관리하는 타입 하나를 관 시설로 만든다.
레거시 측점에는 제원이 없다(누가거리 + 표시 문자열뿐). 값을 지어내지 않고
**레지스트리 기본값**을 그대로 승계한다 — 기슭막이의 형태·높이·길이·전/후는
사용자 확정 기본값이다(2026-08-17·08-19). 옵션 키는 옛 전용 키 한 벌로 두면
B05 폼(`legacyRevetOptions`)과 B06 세트(`_revet_set`)가 양쪽 벽으로 펼쳐 읽는다.
"""
options = {
option.key: option.default
for option in definition.options
if option.phase == "b05" and option.default is not None
}
before = float(options.get("before_m") or 0.0)
after = float(options.get("after_m") or 0.0)
return PipePoint(
chainage_m=chainage,
source=PIPE_SOURCE_USER,
facility=definition.type_id,
start_m=chainage - before,
end_m=chainage + after,
options=options,
)
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 {}