Files
Aislo/B05_Profile/B05_Profile_Structures_Schema.py
T
eomsangdonandClaude Fable 5 84eb027348 fix(B05): 크로스체크 2차 4건 반영 — 우클릭 추가 실패·미협의 기본값·STALE 통지
교차검증 2차에서 재현된 실제 실패를 수정한다.

1. 우클릭 구조물 추가가 대부분 저장 거절되던 문제
   - 메뉴가 레지스트리 앞 12개를 그대로 노출해 배관(별도 정본)과 필수 제원이
     있는 C군이 섞였고, addAt()이 값 없이 즉시 저장해 서버가 거절했다.
     실패 항목은 structure_id 없이 화면에 남아 수정·삭제도 막혔다.
   - 메뉴는 managed_by 없고 required 옵션 없는 타입만 노출한다.
   - 필수 입력 타입은 저장 대신 측점을 채운 사이드 폼을 열고 첫 필수 칸에
     포커스를 준다.
   - 저장이 실패하거나 충돌하면 서버 정본을 다시 받아 화면을 되돌린다.

2. 미협의 선택값이 기본값으로 자동 저장되던 문제
   - 재료·형식 선택형 18건의 default를 없애고 required로 바꿨다.
     화면 select에는 "선택하세요" 빈 항목을 두고, defaultOptions()가 첫
     선택지를 대신 채우던 동작을 없앴다.
   - 남긴 default는 법정 단일값, 사용자 확정값(골막이), 표시용 문자열뿐이다.

3. STALE 갱신 실패를 사용자가 알 수 없던 문제
   - 응답에 needs_downstream_invalidation을 추가해 "되돌려야 했는가"와
     "되돌렸는가"를 구분한다. 어긋나면 화면이 B06 재실행을 안내한다.

4. 회귀 방지: 레지스트리 정책 테스트 신설(우클릭 목록 구성·기본값 원칙).

pytest 49건 통과 · tsc 0 · ruff 통과 · npm run build 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:13:19 +09:00

165 lines
6.6 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
# 미확정 수치(기본값 없음)는 사용자가 직접 넣어야 저장된다 — 지식DB 원칙:
# 기본값 선정은 사용자 협의 영역, 임의값 자동 저장 금지(2026-08-16 크로스체크 반영).
required: bool = False
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 이후를 되돌려야 했는가 / 실제로 되돌렸는가.
# 둘이 어긋나면(필요했는데 못 했다) 화면이 사용자에게 알린다.
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)