- M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖
- 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트
- 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음
- 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면
- 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음
- drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침)
- 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음
- 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리
- 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
296 lines
14 KiB
Python
296 lines
14 KiB
Python
"""B05 구조물 타입 레지스트리·인스턴스 검증 모델.
|
|
|
|
구조물 타입 정의는 코드가 아니라 데이터(M02 마스터 `resources/master_template/구조물정의.json`
|
|
`types` · PLAN 48-1)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는
|
|
`GET /structure-types`로 같은 정본을 받는다. 정의 틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`.
|
|
|
|
인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정).
|
|
DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의
|
|
"복원 유령" 전례).
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
from functools import lru_cache
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from common_util.common_util_summary_item import parse_group_items, parse_summary_item
|
|
|
|
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
|
|
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
|
STRUCTURE_SIDES = ("left", "right", "center", "cross")
|
|
|
|
# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음
|
|
DEFINITION_PATH = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"resources",
|
|
"master_template",
|
|
"구조물정의.json",
|
|
)
|
|
|
|
Placement = Literal["point", "interval", "site"]
|
|
|
|
|
|
class StructureOptionField(BaseModel):
|
|
"""타입별 옵션 입력 한 칸의 정의 — 프론트가 이 스키마로 폼을 그린다."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
key: str
|
|
label: str
|
|
input: Literal["select", "number", "text", "bool"]
|
|
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)
|
|
# 다른 정본이 관리하는 타입(배관 = 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
|
|
|
|
|
|
def _stamp() -> tuple[str, int, int]:
|
|
stat = os.stat(DEFINITION_PATH)
|
|
return DEFINITION_PATH, stat.st_mtime_ns, stat.st_size
|
|
|
|
|
|
@lru_cache(maxsize=4)
|
|
def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
|
|
with open(path, encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
#: 정의에 남은 옛 칸 — 아무도 안 읽어 걷음(323-7 · 마스터는 M02 [저장] 때 빠짐 · 읽을 때 버림).
|
|
RETIRED_TYPE_KEYS = ("drawing_views",)
|
|
|
|
|
|
def without_retired(item: dict[str, Any]) -> dict[str, Any]:
|
|
"""종류 한 줄에서 옛 칸을 뺀 사본."""
|
|
return {k: v for k, v in item.items() if k not in RETIRED_TYPE_KEYS}
|
|
|
|
|
|
@lru_cache(maxsize=4)
|
|
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
|
|
payload = _read_definition(path, mtime, size)
|
|
return tuple(StructureType.model_validate(without_retired(item)) for item in payload["types"])
|
|
|
|
|
|
def load_definition() -> dict[str, Any]:
|
|
"""구조물 정의 한 벌(사본) — `{판, types, 표시, 그룹, 종류, 배치, 규칙, 동작, …}`."""
|
|
return copy.deepcopy(_read_definition(*_stamp()))
|
|
|
|
|
|
def definition_version() -> int:
|
|
return int(_read_definition(*_stamp()).get("판", 1))
|
|
|
|
|
|
def load_structure_types() -> tuple[StructureType, ...]:
|
|
"""정의 정본을 읽어 타입 목록을 돌려준다(파일이 바뀔 때만 다시 읽음)."""
|
|
return _read_types(*_stamp())
|
|
|
|
|
|
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:
|
|
"""서버가 종단 정본에 **측점으로 심는** 타입인가 — 구조물 표 횡단도 부품이 있음(322-1).
|
|
|
|
anchor(기준 한 장 · 횡단배수 · 관 지점 시설) · span3(시작 · 기준 · 끝 · 벽 · 소단 · 기슭막이
|
|
— 길이가 길면 그만큼 여러 장). 옛 군 글자 규칙(A군 · C · D군 구간형)은 표 처음값으로 옮김.
|
|
"""
|
|
from common_util.common_util_structure_catalog import catalog_row
|
|
|
|
row = catalog_row(item.type_id)
|
|
return row is not None and row["station"] != "none"
|
|
|
|
|
|
#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름)
|
|
HISTORY_DIRNAME = "구조물정의_이력"
|
|
|
|
|
|
def registry_schema_version() -> int:
|
|
return int(_read_definition(*_stamp()).get("schema_version", 1))
|
|
|
|
|
|
#: 구조물 id 모양 — 복사본 폴더 이름(`M02_Item_Snapshot.FOLDER_PATTERN`)이 되므로 경로 글자 금지
|
|
STRUCTURE_ID_RULE = r"[0-9A-Za-z][0-9A-Za-z_-]*"
|
|
|
|
|
|
class StructureInstance(BaseModel):
|
|
"""배치된 구조물 1건.
|
|
|
|
배치형태에 따라 위치 필드가 갈린다 — 점형·부지형은 `chainage_m`, 구간형은 `start_m`~`end_m`.
|
|
쓰지 않는 쪽 필드를 함께 받으면 어느 값이 진짜인지 알 수 없으므로 거절한다.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
structure_id: str | None = Field(default=None, pattern=rf"^{STRUCTURE_ID_RULE}$", max_length=64)
|
|
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
|
|
# 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷
|
|
summary_item: dict[str, Any] | None = None
|
|
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
|
|
group_items: dict[str, Any] | None = None
|
|
|
|
@field_validator("summary_item")
|
|
@classmethod
|
|
def validate_summary_item(cls, value: Any) -> dict[str, Any] | None:
|
|
return parse_summary_item(value)
|
|
|
|
@field_validator("group_items")
|
|
@classmethod
|
|
def validate_group_items(cls, value: Any) -> dict[str, Any] | None:
|
|
return parse_group_items(value)
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def follow_site_definition(cls, data: Any) -> Any:
|
|
"""정의가 부지형인데 옛 구간형으로 적힌 저장분(사토장 · PLAN 54-7) — 기준 측점 하나로 읽음.
|
|
|
|
기준 측점이 없으면 옛 기준 → 시작 차례. 파일의 옛 값은 [저장] 전까지 그대로.
|
|
"""
|
|
if not isinstance(data, dict) or data.get("placement") != "interval":
|
|
return data
|
|
definition = structure_type_map().get(str(data.get("type_id")))
|
|
if definition is None or definition.placement != "site":
|
|
return data
|
|
chainage = data.get("chainage_m")
|
|
if chainage is None:
|
|
chainage = data.get("start_m")
|
|
rest = {key: value for key, value in data.items() if key not in ("start_m", "end_m")}
|
|
return {**rest, "placement": "site", "chainage_m": chainage}
|
|
|
|
@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
|
|
# 못 만든 구조물 조합 복사본 폴더(142-2) — 화면이 [저장] 알림 한 줄
|
|
snapshot_failed: list[str] = []
|
|
|
|
|
|
class StructureListResponse(BaseModel):
|
|
status: str = "success"
|
|
project_id: str
|
|
revision: int
|
|
structures: list[StructureInstance] = Field(default_factory=list)
|
|
# 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림
|
|
definition_version: int | None = None
|