Files
Aislo/B05_Profile/B05_Profile_Structures_Migration.py
T
eomsangdonandClaude Opus 5 0979927dfc fix(B05): 구조물 측점을 정본 파일에서 파생하는 단일 공급자를 세운다
[초기화] 후 관 지점 횡단(비정규 측점)이 통째로 사라지던 문제. 측점 정본은
longitudinal.json stations + cross_*.json 파일인데, 그 파일을 쓰는
run_section_generation 에는 공급자가 없었다. 유일한 공급자가 프론트
[임시저장]이라 초기화·반폭 재생성이 파일을 다시 쓸 때마다 측점이 날아갔다
(실측: 초기화 직후 cross 23 -> 19, irregular 4 -> 0).

- resolve_extra_stations 신설 — 관 정본(pipe_points.json) + 구조물 정본 A군에서
  매번 파생한다. 라벨 형식은 프론트 structureLabel()과 같다. 물넘이포장은
  비정규 측점으로 올리지 않는다(2026-08-28 사용자 확정).
- run_section_generation 이 generate_sections 앞에서 이 목록을 심는다. 호출자가
  넘긴 extra_stations 는 무시한다 — DB 스냅샷으로 되쓰이는 값이라 되먹이면
  B04에서 지운 관이 파일 정본을 이기고 부활한다.
- _load_pipe_anchors -> _load_pipe_points. 계획선 정착과 측점 생성이 같은 한 번의
  읽기를 쓴다. 지문 불일치 경고에 버린 건수를 남겨 침묵 실패를 없앤다.
- B06_Section_Router._extra_stations 삭제(자기참조라 늘 빈 값이었다).
  706 -> 689줄로 700줄 규칙 위반도 함께 해소.
- 격자와 같은 정수 미터인 비정규 측점은 격자로 스냅한다 — cross_*.json 파일명이
  정수 미터라 측점 수와 파일 수가 어긋났다.
- 구조물 이관 필터를 A군 6종 라벨로 넓힌다 — 서버가 심은 세월교·BOX암거 라벨을
  "기타"로 재이관하던 고스트를 막는다.

검증: pytest 229 passed / 7 skipped(신규 14건). 공용 브라우저 실측 — 초기화 직후
임시저장 없이 cross 23 / irregular 4(라벨 일치), B06 카드 23장, 반폭 재생성
20->25->20 에도 23 유지, DB cross_sections 23행.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:38:58 +09:00

133 lines
5.3 KiB
Python

"""기존 비정규 측점 → 구조물 정본 마이그레이션.
기존 화면은 구조물을 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,
load_structure_types,
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]:
"""A군(계곡 통과 시설·횡단배수) 표시 이름 — 서버가 종단 정본에 직접 심는 라벨이라
구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). 레지스트리에서 뽑아 목록이
바뀌어도 따라간다(load_structure_types는 lru_cache).
"""
return {item.name for item in load_structure_types() if item.group == "A"}
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 {}