임도 구조물 전체(구조물군 B~G)를 수동 배치할 수 있도록 백엔드 기반을 만든다. 타입 정의를 코드가 아닌 데이터(JSON 레지스트리)로 두어, 구조물 목록이 바뀌어도 코드를 고치지 않고 화면 폼까지 따라오게 했다. - B05_Profile_Structure_Types.json: 타입 레지스트리 정본 (B~G 32종 + 호환 4종). 배치형태(point/interval/site)·옵션 스키마·마크 스타일·drawing_views 메타 포함. - B05_Profile_Structures_Schema.py: 타입·인스턴스 Pydantic 모델. 배치형태별 위치 필드 배타 검증, side·offset_m·placement_source·status·revision. - B05_Profile_Structures_Repository.py: structures.json 단일 정본 읽기·쓰기. atomic_write_json 재사용, base_revision 불일치 시 충돌 예외, 설계 영향 변경만 후속 단계를 무효화하도록 판정하는 지문 비교. - B05_Profile_Structures_Migration.py: 기존 비정규 측점 이관. 배관은 pipe_points.json 정본이라 제외, 기성막이→기슭막이, 대피로→대피소 통합, 나머지는 이름을 살려 기타. 같은 입력이면 같은 결과. - B05_Profile_Structures_Router.py: GET /structure-types, 구조물 목록 조회·저장. 판번호 충돌 409, 미등록·타 정본 관리 타입 400, 저장 후 B06 이후 STALE 전파. 테스트 45건(tmp/tests) 통과. 기존 배관·배수유역·종단 편집 코드는 건드리지 않았다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
6.2 KiB
Python
160 lines
6.2 KiB
Python
"""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
|
|
|
|
|
|
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
|
|
# 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다.
|
|
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 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)
|
|
side: Literal["left", "right", "center", "cross"] = "center"
|
|
offset_m: float = 0.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보다 커야 합니다.")
|
|
if self.chainage_m is not None:
|
|
raise ValueError("구간형 구조물에는 chainage_m을 함께 보낼 수 없습니다.")
|
|
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:
|
|
"""종단도 서클마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정)."""
|
|
return self.start_m if self.placement == "interval" else 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 이후 완료 단계를 STALE로 되돌렸는가.
|
|
invalidated_downstream: bool = False
|
|
|
|
|
|
class StructureListResponse(BaseModel):
|
|
status: str = "success"
|
|
project_id: str
|
|
revision: int
|
|
structures: list[StructureInstance] = Field(default_factory=list)
|