"""B05 구조물 타입 레지스트리·인스턴스 검증 모델. 구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다. 인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정). DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의 "복원 유령" 전례). """ import json import os from functools import lru_cache from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator # 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). STRUCTURE_PLACEMENTS = ("point", "interval", "site") STRUCTURE_SIDES = ("left", "right", "center", "cross") _REGISTRY_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json" ) Placement = Literal["point", "interval", "site"] class StructureOptionField(BaseModel): """타입별 옵션 입력 한 칸의 정의 — 프론트가 이 스키마로 폼을 그린다.""" model_config = ConfigDict(extra="forbid") key: str label: str input: Literal["select", "number", "text"] choices: list[str] = Field(default_factory=list) unit: str | None = None default: Any = None # 미확정 수치(기본값 없음)는 사용자가 직접 넣어야 저장된다 — 지식DB 원칙: # 기본값 선정은 사용자 협의 영역, 임의값 자동 저장 금지(2026-08-16 크로스체크 반영). required: bool = False # 입력 시점 — B05는 유무·종류·위치만 고르고 상세 치수는 B06/B07에서 받는다 # (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다 # — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다. phase: Literal["b05", "detail"] = "b05" # 폼에 칸은 두되 **지금은 못 고르게** 할 때 거짓으로 둔다 — 회색으로 그려지고 값은 # 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에 # 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다. enabled: bool = True # ── 「기본값도 필수도 아닌」 세 번째 갈래 (2026-09-09 신설) ──────────────────── # 지금까지 칸은 둘 중 하나여야 했다 — **기본값이 있거나, 필수이거나**. 그러지 않으면 # 빈 값이 조용히 저장되기 때문이다. 그런데 실제로는 셋째가 있다: # **「비워 두는 것이 뜻인 칸」** — 비면 계산 쪽이 **기준값**으로 돌고 그 사실이 화면에 # 사유로 뜬다. 값을 넣으면 그 값이 이긴다(예: 전면 기울기, 물빼기 구멍, 뒷길이). # 여기에 기준의 출처를 **한 줄로 적어** 두면, 그 칸이 왜 비어 있어도 되는지가 # **정본 파일 안에** 남는다. 시험 예외 목록(창마다 따로 사는 파일)로 두면 창이 갈릴 때 # 같은 시험이 다른 창에서 깨진다 — 실제로 그렇게 깨졌다(2026-09-09). empty_means: str | None = None # 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」). # 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다. default_basis: str | None = None # 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」). # 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호). warn_above: float | None = None warn_message: str | None = None # 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①). # 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴. not_in_table: str | None = None class StructureType(BaseModel): """구조물 타입 정의(레지스트리 1행).""" model_config = ConfigDict(extra="forbid") type_id: str group: str name: str placement: Placement options: list[StructureOptionField] = Field(default_factory=list) style: dict[str, str] = Field(default_factory=dict) # B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다. drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 # (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고, # ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다. # # 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로 # (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면 # **같은 것을 두 번 계상**한다(2026-09-07 조사). design_owner: str | None = None # 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다. reference_only: bool = False enabled: bool = True @lru_cache(maxsize=1) def load_structure_types() -> tuple[StructureType, ...]: """레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드).""" with open(_REGISTRY_PATH, encoding="utf-8") as handle: payload = json.load(handle) return tuple(StructureType.model_validate(item) for item in payload["types"]) def structure_type_map() -> dict[str, StructureType]: return {item.type_id: item for item in load_structure_types()} def is_station_planting_type(item: StructureType) -> bool: """서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28). - A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다. - D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면 그만큼 여러 장이 나온다(사용자 확정). 측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단 (`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다. """ if item.group == "A": return True return item.group == "D" and item.placement == "interval" def station_planting_labels() -> set[str]: """서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다.""" return {item.name for item in load_structure_types() if is_station_planting_type(item)} def registry_schema_version() -> int: with open(_REGISTRY_PATH, encoding="utf-8") as handle: return int(json.load(handle).get("schema_version", 1)) class StructureInstance(BaseModel): """배치된 구조물 1건. 배치형태에 따라 위치 필드가 갈린다 — 점형·부지형은 `chainage_m`, 구간형은 `start_m`~`end_m`. 쓰지 않는 쪽 필드를 함께 받으면 어느 값이 진짜인지 알 수 없으므로 거절한다. """ model_config = ConfigDict(extra="forbid") structure_id: str | None = None type_id: str placement: Placement chainage_m: float | None = Field(default=None, ge=0) start_m: float | None = Field(default=None, ge=0) end_m: float | None = Field(default=None, ge=0) options: dict[str, Any] = Field(default_factory=dict) memo: str = "" placement_source: Literal["manual", "suggested", "automatic"] = "manual" status: Literal["draft", "confirmed"] = "draft" revision: int = Field(default=0, ge=0) # 부지형 polygon 예약 — 1차 UI는 측점+면적 속성만 쓴다. geometry: dict[str, Any] | None = None @model_validator(mode="after") def validate_placement_fields(self) -> "StructureInstance": if self.placement == "interval": if self.start_m is None or self.end_m is None: raise ValueError("구간형 구조물은 start_m과 end_m이 모두 필요합니다.") if self.end_m <= self.start_m: raise ValueError("구간형 구조물의 end_m은 start_m보다 커야 합니다.") # 구간형 chainage_m = 기준점(마킹 위치). 기존 저장분에는 없으므로 시점으로 # 채운다 (2026-08-17 사용자 확정: 기준점에 마킹 + 시작·종료 측점). if self.chainage_m is None: self.chainage_m = self.start_m elif not (self.start_m <= self.chainage_m <= self.end_m): raise ValueError("구간형 구조물의 기준점은 시작~종료 측점 안에 있어야 합니다.") else: if self.chainage_m is None: raise ValueError("점형·부지형 구조물은 chainage_m이 필요합니다.") if self.start_m is not None or self.end_m is not None: raise ValueError("점형·부지형 구조물에는 start_m·end_m을 보낼 수 없습니다.") return self def anchor_m(self) -> float: """종단도 서클마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다 (2026-08-17 사용자 확정 — validator가 미지정 시 시점으로 채워 둔다).""" return self.chainage_m # type: ignore[return-value] class StructureTypesResponse(BaseModel): """`GET /structure-types` 응답.""" status: str = "success" schema_version: int types: list[StructureType] class StructureSaveRequest(BaseModel): """구조물 목록 저장 요청 — 목록 전체를 정본으로 덮어쓴다. `base_revision`은 화면이 읽어간 정본의 판번호다. 그 사이 다른 창이 저장했으면 번호가 어긋나므로 409로 거절하고, 사용자가 최신본을 받아 다시 편집하게 한다. """ model_config = ConfigDict(extra="forbid") base_revision: int = Field(ge=0) structures: list[StructureInstance] = Field(default_factory=list) class StructureSaveResponse(BaseModel): status: str = "success" project_id: str revision: int count: int # 설계 영향 변경이라 B06 이후를 되돌려야 했는가 / 실제로 되돌렸는가. # 둘이 어긋나면(필요했는데 못 했다) 화면이 사용자에게 알린다. needs_downstream_invalidation: bool = False invalidated_downstream: bool = False class StructureListResponse(BaseModel): status: str = "success" project_id: str revision: int structures: list[StructureInstance] = Field(default_factory=list)