feat(M02): 48-1 구조물 정의 한 벌 — 정의 틀 문서 · 판 1 옮김 · 읽기 길

- 정의 틀 = docs/raw/M02_구조물옵션정의/정의_틀.md (담는 것 · 키 규칙 · 꼴 · 배치 · 그룹 형상 · 규칙 · 동작 · 판 이력)
- resources/master_template/구조물정의.json 판 1 = 옛 B05 레지스트리(37종 · 옵션 235) + 옵션표시 + 옵션그룹 키 그대로 옮김
- GET /api/m02/structure-definition(로그인) · PUT(시스템 관리자만 · 판 +1 · 이력 판_N) · 검사(꼴 · 키 겹침 · 규칙 대상)
- GET /structure-types · B05 구조물 검사가 새 정의를 읽음(응답 꼴 그대로) · 옵션 보이기 · 그룹 저장도 정의 칸으로
- 프로젝트 structures.json 에 저장 때 정의 판 · 정의에 없는 키 값은 남김(오류 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-05 16:09:33 +09:00
co-authored by Claude Opus 5.5
parent 4600b9970c
commit bf1fa50817
22 changed files with 1194 additions and 285 deletions
@@ -1,45 +1,34 @@
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
정본 = `resources/master_template/구조물옵션그룹.json` =
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
정본 = 구조물 정의 한 벌(`M02_SO_Definition` · PLAN 48-1)의 「그룹」 칸 =
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": [...], "형상"?: {...}}]}`
— 없는 종류 = 그룹 없음 · 빈 그룹(옵션 0)도 그룹 · 형상 = 길이 · 높이 · 전 · 후 받기 + 뜻.
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from common_util.common_util_json import atomic_write_json
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "구조물옵션그룹.json"
GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
NAME_MAX = 40
SHAPE = ("길이", "높이", "전", "후") # 그룹이 받을 형상 칸 — `{칸: {받기: 참거짓, 뜻: 글}}`
logger = logging.getLogger(__name__)
def _load() -> tuple[dict[str, Any], str]:
"""(그룹 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
path = store.FOLDER / FILE
if not path.is_file():
return {}, ""
raw = path.read_bytes()
try:
doc = json.loads(raw)
except ValueError as e:
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
if not isinstance(doc, dict):
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
return doc, store.version_of(raw)
"""(그룹 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「그룹」 칸 · 판 = 정의 판 글."""
doc = load_definition()
return doc.get("그룹") or {}, str(doc.get("판", 1))
def groups_of() -> dict[str, list[dict[str, Any]]]:
@@ -83,11 +72,23 @@ def listing() -> dict[str, Any]:
return {"판": version, "그룹": doc, "종류": kinds}
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
def _shape_ok(shape: Any) -> bool:
return isinstance(shape, dict) and all(
k in SHAPE
and isinstance(v, dict)
and isinstance(v.get("받기"), bool)
and isinstance(v.get("뜻", ""), str)
and set(v) <= {"받기", "뜻"}
for k, v in shape.items()
)
def _clean(groups: Any, keys: dict[str, set[str]] | None = None) -> dict[str, list[dict[str, Any]]]:
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹 · 틀린 형상 422."""
if not isinstance(groups, dict):
raise store.StoreError(400, "그룹은 종류별 묶음이어야 함")
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
if keys is None:
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
errors: list[str] = []
out: dict[str, list[dict[str, Any]]] = {}
for type_id, rows in groups.items():
@@ -126,9 +127,14 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
)
else:
owner[key] = name
if "형상" in row and not _shape_ok(row["형상"]):
errors.append(
f"「{type_id}.{name}」 형상은 {{길이 · 높이 · 전 · 후: {{받기, 뜻}}}}"
)
ids.add(gid)
names.add(name)
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
kept = {"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]}
clean.append({**kept, "형상": row["형상"]} if "형상" in row else kept)
if clean:
out[type_id] = clean
if errors:
@@ -138,11 +144,7 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
def save(groups: Any, version: str) -> dict[str, Any]:
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
fixed = _clean(groups)
path = store.FOLDER / FILE
with store._LOCK:
have = store.version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
atomic_write_json(path, fixed)
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
save_sections({"그룹": _clean(groups)}, version)
return listing()