"""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 FILE = "구조물정의.json" HISTORY = "구조물정의_이력" 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 _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]: """종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름]}`.""" 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 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) -> list[str]: """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과).""" 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)] return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) 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) 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)