- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로] - 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만 - 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만) - 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참 - 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만 - 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름 - 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번 - 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""M02 구조물 정의 한 벌(PLAN 48-1) — 정본 `resources/master_template/구조물정의.json`.
|
|
|
|
틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만.
|
|
판 = 정수 · 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` 으로 남김(지난 정의 값 읽기용).
|
|
검사 = 칸 꼴 · 키 겹침 · 표시 · 그룹 · 배치 · 규칙 대상 있음 — 틀리면 422 · 낡은 판 409.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from B05_Profile import B05_Profile_Structures_Schema as schema
|
|
from common_util.common_util_json import atomic_write_json
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
|
|
|
|
FILE = "구조물정의.json"
|
|
HISTORY = "구조물정의_이력"
|
|
# 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`)
|
|
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"}
|
|
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
|
|
SPOT = ("줄", "칸", "너비")
|
|
|
|
|
|
def read() -> dict[str, Any]:
|
|
return schema.load_definition()
|
|
|
|
|
|
def history_path(version: int) -> Path:
|
|
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
|
|
|
|
|
|
def history() -> list[dict[str, Any]]:
|
|
"""지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지)."""
|
|
folder = history_path(1).parent
|
|
rows = []
|
|
for path in folder.glob("판_*.json") if folder.is_dir() else []:
|
|
number = path.stem.removeprefix("판_")
|
|
if number.isdigit():
|
|
when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
rows.append({"판": int(number), "저장일": when})
|
|
return sorted(rows, key=lambda r: r["판"], reverse=True)
|
|
|
|
|
|
def read_version(version: int) -> dict[str, Any]:
|
|
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
|
path = history_path(version)
|
|
if not path.is_file():
|
|
raise store.StoreError(404, f"판 {version} 이력이 없음")
|
|
return json.loads(path.read_text("utf-8"))
|
|
|
|
|
|
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
|
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
|
|
errors: list[str] = []
|
|
keys: dict[str, dict[str, Any]] = {}
|
|
groups = doc.get("구조물군")
|
|
if not isinstance(groups, dict):
|
|
errors.append("구조물군은 {키: 이름} 묶음이어야 함")
|
|
groups = {}
|
|
for row in doc.get("types") if isinstance(doc.get("types"), list) else []:
|
|
try:
|
|
t = schema.StructureType.model_validate(row)
|
|
except ValidationError as e:
|
|
errors.append(
|
|
f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}"
|
|
)
|
|
continue
|
|
if t.type_id in keys:
|
|
errors.append(f"종류 키 「{t.type_id}」 겹침")
|
|
if t.group not in groups:
|
|
errors.append(f"「{t.type_id}」 구조물군 「{t.group}」 이 구조물군에 없음")
|
|
opts: dict[str, Any] = {}
|
|
for o in t.options:
|
|
if o.key in opts:
|
|
errors.append(f"「{t.type_id}」 옵션 키 「{o.key}」 겹침")
|
|
if o.input == "select" and not o.choices:
|
|
errors.append(f"「{t.type_id}.{o.key}」 고르기 꼴은 선택지가 있어야 함")
|
|
opts[o.key] = o
|
|
keys[t.type_id] = opts
|
|
if not isinstance(doc.get("types"), list) or not keys:
|
|
errors.append("types 는 종류 목록이어야 함")
|
|
return errors, keys
|
|
|
|
|
|
def _names_ok(value: Any) -> bool:
|
|
"""구조물 명세 이름 바꿈 표 — `{컨테이너[.항목[=값]]: 빈 글 아닌 이름}`."""
|
|
return isinstance(value, dict) and all(
|
|
isinstance(k, str) and isinstance(v, str) and v.strip() for k, v in value.items()
|
|
)
|
|
|
|
|
|
def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
|
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름], 이름: {명세 이름 키: 이름}}`(이름 = PLAN 52)."""
|
|
errors: list[str] = []
|
|
acts = doc.get("동작") if isinstance(doc.get("동작"), dict) else {}
|
|
for tid, row in doc["종류"].items():
|
|
if tid not in keys or not isinstance(row, dict):
|
|
errors.append(f"종류 덧칸 「{tid}」 — 없는 종류이거나 묶음 아님")
|
|
continue
|
|
for name, value in row.items():
|
|
if name == "기준측점" and isinstance(value, bool):
|
|
continue
|
|
if name == "동작" and isinstance(value, list) and all(a in acts for a in value):
|
|
continue
|
|
if name == "이름" and _names_ok(value):
|
|
continue
|
|
errors.append(
|
|
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)"
|
|
)
|
|
return errors
|
|
|
|
|
|
def _spot_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
|
"""배치 — `{종류: {옵션 키: {줄, 칸, 너비}}}` · 1 이상 정수."""
|
|
errors: list[str] = []
|
|
for tid, row in doc["배치"].items():
|
|
if tid not in keys or not isinstance(row, dict):
|
|
errors.append(f"배치 「{tid}」 — 없는 종류이거나 묶음 아님")
|
|
continue
|
|
for key, spot in row.items():
|
|
ok = isinstance(spot, dict) and set(spot) <= set(SPOT)
|
|
ok = ok and all(type(v) is int and v >= 1 for v in spot.values())
|
|
if key not in keys[tid] or not ok:
|
|
errors.append(
|
|
f"배치 「{tid}.{key}」 — 없는 옵션이거나 줄 · 칸 · 너비가 1 이상 정수 아님"
|
|
)
|
|
return errors
|
|
|
|
|
|
def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
|
"""규칙 — `{종류, 만약: {옵션, 값들}, 그러면: {옵션, 선택지? · 보이기? · 기본값?}}`."""
|
|
errors: list[str] = []
|
|
for i, rule in enumerate(doc["규칙"], 1):
|
|
where = f"규칙 {i}"
|
|
if not isinstance(rule, dict) or rule.get("종류") not in keys:
|
|
errors.append(f"{where} — 없는 종류")
|
|
continue
|
|
opts = keys[rule["종류"]]
|
|
when, then = rule.get("만약"), rule.get("그러면")
|
|
if not isinstance(when, dict) or not isinstance(then, dict):
|
|
errors.append(f"{where} — 만약 · 그러면은 묶음이어야 함")
|
|
continue
|
|
src, dst = opts.get(when.get("옵션")), opts.get(then.get("옵션"))
|
|
if src is None or dst is None or src is dst:
|
|
errors.append(f"{where} — 만약 · 그러면 옵션이 없거나 같음")
|
|
continue
|
|
values = when.get("값들")
|
|
if not isinstance(values, list) or not values:
|
|
errors.append(f"{where} — 값들이 비었음")
|
|
elif src.input == "select" and not set(map(str, values)) <= set(src.choices):
|
|
errors.append(f"{where} — 값들이 「{src.key}」 선택지에 없음")
|
|
effects = set(then) - {"옵션"}
|
|
if not effects or not effects <= {"선택지", "보이기", "기본값"}:
|
|
errors.append(f"{where} — 그러면은 선택지 · 보이기 · 기본값 중 하나 이상")
|
|
allowed = dst.choices
|
|
if "선택지" in then:
|
|
pick = then["선택지"]
|
|
if dst.input != "select" or not isinstance(pick, list) or not set(pick) <= set(allowed):
|
|
errors.append(f"{where} — 선택지는 「{dst.key}」 고르기 선택지 안에서만")
|
|
else:
|
|
allowed = pick
|
|
if "보이기" in then and not isinstance(then["보이기"], bool):
|
|
errors.append(f"{where} — 보이기는 참거짓")
|
|
if "기본값" in then and dst.input == "select" and str(then["기본값"]) not in allowed:
|
|
errors.append(f"{where} — 기본값이 「{dst.key}」 선택지에 없음")
|
|
return errors
|
|
|
|
|
|
def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]:
|
|
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2)."""
|
|
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
|
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
|
|
|
if not isinstance(doc, dict):
|
|
return ["정의는 묶음이어야 함"]
|
|
errors = [f"모르는 칸 「{k}」" for k in doc if k not in TOP and k not in SECTIONS]
|
|
errors += [
|
|
f"「{k}」 꼴이 틀림" for k, kind in SECTIONS.items() if not isinstance(doc.get(k), kind)
|
|
]
|
|
if not isinstance(doc.get("동작"), dict):
|
|
errors.append("동작은 {이름: 뜻} 묶음이어야 함")
|
|
if errors:
|
|
return errors
|
|
found, keys = _type_errors(doc)
|
|
errors += found
|
|
plain = {tid: set(opts) for tid, opts in keys.items()}
|
|
for fn, section in ((options._clean, "표시"), (groups._clean, "그룹")):
|
|
try:
|
|
fn(doc[section], plain)
|
|
except store.StoreError as e:
|
|
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
|
|
errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
|
|
return errors + ([] if errors else catalog_errors(doc, before))
|
|
|
|
|
|
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
|
|
"""판이 같을 때만 고쳐 씀 — 앞 판은 이력으로 · 새 판 = 앞 판 + 1."""
|
|
path = Path(schema.DEFINITION_PATH)
|
|
with store._LOCK:
|
|
current = read()
|
|
have = int(current.get("판", 1))
|
|
if str(version) != str(have):
|
|
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
|
doc = {**change(current), "판": have}
|
|
errors = check(doc, current)
|
|
if errors:
|
|
raise store.StoreError(422, {"errors": errors})
|
|
atomic_write_json(history_path(have), current)
|
|
atomic_write_json(path, {**doc, "판": have + 1})
|
|
return read()
|
|
|
|
|
|
def save(doc: Any, version: Any) -> dict[str, Any]:
|
|
"""정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의."""
|
|
if not isinstance(doc, dict):
|
|
raise store.StoreError(400, "정의는 묶음이어야 함")
|
|
return _commit(lambda _: dict(doc), version)
|
|
|
|
|
|
def save_sections(changes: dict[str, Any], version: Any) -> dict[str, Any]:
|
|
"""정의 몇 칸만 고쳐 씀(옵션 보이기 · 그룹 화면)."""
|
|
return _commit(lambda current: {**current, **changes}, version)
|