From bf1fa5081793b253406c2e55d2113cd919211ebc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:09:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=2048-1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=ED=8B=80=20=EB=AC=B8=EC=84=9C=20=C2=B7=20?= =?UTF-8?q?=ED=8C=90=201=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 틀 = 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 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B05_Profile_Structures_Repository.py | 13 +- B05_Profile/B05_Profile_Structures_Schema.py | 52 +- .../StructOptions/M02_SO_Api.ts | 4 +- .../StructOptions/M02_SO_Definition.py | 195 ++++ .../StructOptions/M02_SO_Groups.py | 64 +- .../StructOptions/M02_SO_Options.py | 40 +- .../StructOptions/M02_SO_Options.ts | 2 +- .../StructOptions/M02_SO_Router.py | 25 +- docs/raw/M02_구조물옵션정의/정의_틀.md | 101 ++ resources/master_template/구조물옵션그룹.json | 23 - resources/master_template/구조물옵션표시.json | 1 - .../master_template/구조물정의.json | 860 +++++++++++++++--- .../tester/test_b05_structures_repository.py | 9 +- .../tester/test_b06_box_thickness_registry.py | 2 +- resources/tester/test_b06_pipe_form_keys.py | 2 +- .../tester/test_b08_dry_height_face_role.py | 2 +- resources/tester/test_b08_option_keys.py | 2 +- resources/tester/test_b08_preparation.py | 2 +- .../tester/test_b08_structure_table_holes.py | 2 +- resources/tester/test_b08_wing_wall.py | 2 +- .../test_m02_structure_option_groups.py | 32 +- .../tester/test_m02_structure_options.py | 44 +- 22 files changed, 1194 insertions(+), 285 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Definition.py create mode 100644 docs/raw/M02_구조물옵션정의/정의_틀.md delete mode 100644 resources/master_template/구조물옵션그룹.json delete mode 100644 resources/master_template/구조물옵션표시.json rename B05_Profile/B05_Profile_Structure_Types.json => resources/master_template/구조물정의.json (83%) diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index 273874bef..bc2ad31ed 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -12,7 +12,11 @@ import os import uuid from typing import Any, Iterable -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureInstance, + definition_version, + structure_type_map, +) from common_util.common_util_json import atomic_write_json from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys @@ -125,6 +129,8 @@ def save_structures( "revision": revision, "structures": [item.model_dump(mode="json") for item in items], "migrated_legacy": sorted(history), + # 저장 때 구조물 정의 판(PLAN 48-1) — 뒤에 정의가 바뀌어도 이 값이 어느 정의로 적혔는지 앎 + "definition_version": definition_version(), } atomic_write_json(structures_file_path(project_root), payload) return revision @@ -192,12 +198,15 @@ def _validate_options(item: StructureInstance, definition, hidden: set[str] = fr for key, value in item.options.items(): option = allowed.get(key) if option is None: - raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}") + # 지금 정의에 없는 키 = 지난 정의 값 — 버리지 않고 그대로 남김(PLAN 48-0 ② · 오류 없음) + continue if option.input == "number": if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.") if not (value == value and abs(value) != float("inf")) or value < 0: raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.") + elif option.input == "bool" and not isinstance(value, bool): + raise ValueError(f"{definition.name}의 {option.label}은(는) 예 · 아니오여야 합니다.") elif option.input == "select" and option.choices and str(value) not in option.choices: raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}") for option in definition.options: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index b98fe87e4..3c37ca035 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -1,13 +1,15 @@ """B05 구조물 타입 레지스트리·인스턴스 검증 모델. -구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가 -바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다. +구조물 타입 정의는 코드가 아니라 데이터(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 @@ -21,8 +23,12 @@ from common_util.common_util_summary_item import parse_group_items, parse_summar 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" +# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음 +DEFINITION_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "resources", + "master_template", + "구조물정의.json", ) Placement = Literal["point", "interval", "site"] @@ -35,7 +41,7 @@ class StructureOptionField(BaseModel): key: str label: str - input: Literal["select", "number", "text"] + input: Literal["select", "number", "text", "bool"] choices: list[str] = Field(default_factory=list) unit: str | None = None default: Any = None @@ -99,14 +105,37 @@ class StructureType(BaseModel): 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) +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) + + +@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(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()} @@ -132,8 +161,7 @@ def station_planting_labels() -> set[str]: def registry_schema_version() -> int: - with open(_REGISTRY_PATH, encoding="utf-8") as handle: - return int(json.load(handle).get("schema_version", 1)) + return int(_read_definition(*_stamp()).get("schema_version", 1)) class StructureInstance(BaseModel): diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index a8bafd1fa..0d0ec7b75 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_SO_Api.ts * 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py` - * (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`) + * (마스터 템플릿 `구조물정의.json` 「표시」 · 「그룹」 칸 · PLAN 48-1) * ========================================================================== */ import { call } from "../M02_MasterTemplete_Api_Fetch"; @@ -29,7 +29,7 @@ export const fetchStructureOptions = (): Promise => export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); -/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */ +/* --- 옵션 그룹(PLAN 46-2 · 구조물 정의 「그룹」 칸 · 시스템 관리자만) --- */ /** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */ export interface OptionGroupDef { diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py new file mode 100644 index 000000000..640d6942a --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -0,0 +1,195 @@ +"""M02 구조물 정의 한 벌(PLAN 48-1) — 정본 `resources/master_template/구조물정의.json`. + +틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만. +판 = 정수 · 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` 으로 남김(지난 정의 값 읽기용). +검사 = 칸 꼴 · 키 겹침 · 표시 · 그룹 · 배치 · 규칙 대상 있음 — 틀리면 422 · 낡은 판 409. +""" + +from __future__ import annotations + +from collections.abc import Callable +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 _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) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.py b/M02_MasterTemplete/StructOptions/M02_SO_Groups.py index 6a23fb62e..f103f8edc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Groups.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.py @@ -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() diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.py b/M02_MasterTemplete/StructOptions/M02_SO_Options.py index 18ad33184..c059859e1 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.py @@ -1,15 +1,14 @@ """M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크. -정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}` -— 없는 종류 · 키는 보임(파일이 없어도 모두 보임). +정본 = 구조물 정의(`M02_SO_Definition` · PLAN 48-1) 「표시」 칸 = `{type_id: {option_key: false}}` +— 없는 종류 · 키는 보임. 저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`. 옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`). -판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409. +판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력. """ from __future__ import annotations -import json import logging from typing import Any @@ -19,14 +18,13 @@ from B05_Profile.B05_Profile_Structures_Schema import ( StructureOptionField, StructureType, StructureTypesResponse, + load_definition, load_structure_types, registry_schema_version, ) -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_Groups import groups_of -FILE = "구조물옵션표시.json" logger = logging.getLogger(__name__) @@ -56,18 +54,9 @@ class TypesWithVisible(StructureTypesResponse): 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 _shown(doc: dict[str, Any], type_id: str, key: str) -> bool: @@ -138,11 +127,12 @@ def listing() -> dict[str, Any]: return {"판": version, "표시": hidden, "종류": kinds} -def _clean(shown: Any) -> dict[str, dict[str, bool]]: +def _clean(shown: Any, keys: dict[str, set[str]] | None = None) -> dict[str, dict[str, bool]]: """받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422.""" if not isinstance(shown, 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, dict[str, bool]] = {} for type_id, row in shown.items(): @@ -165,11 +155,7 @@ def _clean(shown: Any) -> dict[str, dict[str, bool]]: def save(shown: Any, version: str) -> dict[str, Any]: """고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`.""" - fixed = _clean(shown) - 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(shown)}, version) return listing() diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index d340d13f6..6fac4334a 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -4,7 +4,7 @@ * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). - * 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`). + * 서버 = `M02_SO_Options.py`(정의 「표시」 · 숨긴 것만) · `M02_SO_Groups.py`(정의 「그룹」). * ========================================================================== */ import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index d417fc2e9..0dbfc8a22 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -1,7 +1,8 @@ -"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`. +"""M02 구조물 옵션 보이기 · 그룹 · 정의 길 — 정본 `M02_SO_Definition.py`(PLAN 48-1) 한 벌. `router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) · -`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임). +`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임) · 정의 쓰기는 여기서 +시스템 관리자만(`access.require(write=True)` · 아니면 403). """ from __future__ import annotations @@ -14,6 +15,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions import M02_SO_Options as options @@ -31,6 +33,11 @@ class GroupsBody(BaseModel): 그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]} +class DefinitionBody(BaseModel): + 판: int # 읽어 간 정의 판 — 어긋나면 409 + 정의: dict[str, Any] # 정의 한 벌(틀 = docs/raw/M02_구조물옵션정의/정의_틀.md) + + def _call(fn, *args): with store.at(store.FOLDER): try: @@ -68,3 +75,17 @@ def get_structure_options_read(session=Depends(verify_session)) -> dict: """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만).""" access.require(session, "system", None) return _call(options.listing) + + +@read_router.get("/structure-definition") +def get_structure_definition(session=Depends(verify_session)) -> dict: + """구조물 정의 한 벌(판 · 구조물군 · 동작 · types · 종류 · 표시 · 그룹 · 배치 · 규칙).""" + access.require(session, "system", None) + return _call(definition.read) + + +@read_router.put("/structure-definition") +def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict: + """정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력.""" + access.require(session, "system", None, write=True) + return _call(definition.save, body.정의, body.판) diff --git a/docs/raw/M02_구조물옵션정의/정의_틀.md b/docs/raw/M02_구조물옵션정의/정의_틀.md new file mode 100644 index 000000000..c64e03855 --- /dev/null +++ b/docs/raw/M02_구조물옵션정의/정의_틀.md @@ -0,0 +1,101 @@ +# 구조물 정의 틀 (PLAN 48-1) + +정본 = `resources/master_template/구조물정의.json` 한 벌. 서버 · 화면은 이 한 벌만 읽음. + +## 1. 길 + +| 길 | 누가 | 하는 것 | +| --- | --- | --- | +| `GET /api/m02/structure-definition` | 로그인 누구나 | 정의 한 벌 그대로 | +| `PUT /api/m02/structure-definition` `{판, 정의}` | 시스템 관리자만(아니면 403) | 통째로 고쳐 씀 · 낡은 판 409 · 검사 틀림 422 | +| `GET /api/projects/structure-types` | 로그인 | 정의의 `types` + `표시` · `그룹` 을 옛 꼴 그대로(`visible` · `groups`) | +| `PUT /api/m02/structure-options` · `structure-option-groups` | 시스템 관리자 | 정의의 `표시` · `그룹` 칸만 고쳐 씀(같은 판 · 같은 이력) | + +- 서버 읽기 = `B05_Profile_Structures_Schema.load_definition()` · `load_structure_types()` · 쓰기 = `M02_SO_Definition.save()` · `save_sections()`. + +## 2. 담는 것 + +| 칸 | 꼴 | 뜻 | +| --- | --- | --- | +| `판` | 정수 | 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` | +| `schema_version` · `comment` | 수 · 글 | 옛 레지스트리 머리 그대로 | +| `구조물군` | `{군 키: 이름}` | 구조물군 이름(A 횡단배수 …) | +| `동작` | `{동작 이름: 뜻}` | 손코딩 특수 동작 목록(5장) | +| `types` | 종류 목록 | 종류 · 옵션 · 선택지 · 기본값 · placement 등(옛 `B05_Profile_Structure_Types.json` 꼴 그대로) | +| `종류` | `{종류 키: {기준측점?, 동작?}}` | 종류마다 덧칸 — 없으면 기본 | +| `표시` | `{종류 키: {옵션 키: false}}` | 끈 옵션만(옛 `구조물옵션표시.json`) | +| `그룹` | `{종류 키: [그룹]}` | 옵션 그룹(옛 `구조물옵션그룹.json` · 46장) + 형상 칸 | +| `배치` | `{종류 키: {옵션 키: {줄, 칸, 너비}}}` | B05 · B06 좌측 「구조물 배치」 칸 자리 — 없으면 지금 차례 | +| `규칙` | 규칙 목록 | 상위 옵션 값 → 하위 옵션(4장) | + +## 3. 키 규칙 + +- 키 = 만들 때 자동 · 안 바뀜 · 영문 소문자 · 숫자 · `_`. 이름(`name` · `label` · 선택지 글 · 그룹 `이름`)은 자유. +- 종류 키(`type_id`) 는 정의 안에서 하나 · 옵션 키(`key`) 는 종류 안에서 하나 · 그룹 id 는 `g` + 소문자 · 숫자. +- 지움 = 「안 씀」(`enabled: false`) — 종류 · 옵션을 목록에서 빼지 않음. +- 저장된 값의 키가 지금 정의에 없으면 = 지난 정의 값 — 버리지 않고 남김(오류 없음 · 계산은 없는 값으로). +- 프로젝트 `structures.json` 에 저장 때 정의 판(`definition_version`). + +## 4. 옵션 + +### 꼴 + +| `input` | 이름 | 값 | +| --- | --- | --- | +| `select` | 고르기 | `choices` 중 하나(선택지 있어야 함) | +| `number` | 숫자 | 0 이상 수 · `unit` | +| `text` | 글 | 글 | +| `bool` | 예/아니오 | 참 · 거짓 | + +- 옵션 덧칸(옛 그대로) = `label` · `choices` · `unit` · `default` · `required` · `phase`(b05 · detail) · `enabled` · `empty_means` · `default_basis` · `warn_above` · `warn_message` · `not_in_table`. + +### 배치 칸 + +- `줄` = 몇째 줄(1부터) · `칸` = 그 줄 몇째 칸(1부터) · `너비` = 칸 몇 개 차지(1부터). 셋 다 1 이상 정수. + +### 그룹 + +``` +{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["옵션 키", …], + "형상": {"길이": {"받기": true, "뜻": "유입구 기슭막이 연장"}, "높이": {…}, "전": {…}, "후": {…}}} +``` + +- 옵션 하나는 한 종류 안에서 그룹 하나에만 · 빈 그룹(옵션 0)도 그룹 · 그룹에 안 넣은 옵션 = 본체 칸. +- `형상` = 그룹이 받을 형상 칸(길이 · 높이 · 기준 측점 전 · 후) — 칸마다 `받기`(켬끔) + `뜻`(무엇을 정하는지). 없는 칸 = 안 받음. + +### 종류 덧칸 + +- `기준측점` = 종류 맨 위 기준 측점 칸 보이기(없으면 보임). +- `동작` = 이 종류에 붙인 특수 동작 이름들(`동작` 목록 안에서). + +## 5. 규칙 꼴 + +``` +{"종류": "종류 키", + "만약": {"옵션": "상위 옵션 키", "값들": ["값", …]}, + "그러면": {"옵션": "하위 옵션 키", "선택지": ["값", …], "보이기": false, "기본값": "값"}} +``` + +- 만약 상위 옵션 = 값들 중 하나 → 그러면 하위 옵션의 선택지 / 보이기 / 기본값(셋 중 하나 이상). +- 상위 · 하위는 같은 종류 안 · 서로 다른 옵션 · 고르기 꼴이면 값들은 선택지 안 · 하위 `선택지` 는 고르기 꼴의 선택지 안 · `기본값` 은 (좁힌) 선택지 안. +- 계산 · 연동 제어는 넣지 않음(48-5). + +## 6. 동작 이름(손코딩 특수 동작) + +| 이름 | 붙은 종류 | 하는 것 | +| --- | --- | --- | +| `계곡통과시설` | pipe · box_culvert · ford_pavement · ford_bridge · revetment | 관 지점 정본 시설 — 부속 옵션 서브폼 · 저장은 폼이 아는 칸만 | +| `유입유출_묶음` | pipe | 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹 | +| `기슭막이_한벌` | pipe · revetment | 형태 · 길이 · 높이 · 바닥 보호공 한 벌 | +| `BOX_본체규격` | box_culvert | 본체 규격 프리셋(폭 × 높이) | +| `날개벽` | box_culvert · ford_bridge | 날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 | +| `월류단면` | ford_pavement · ford_bridge | 월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 · 필요 수심 미만 되돌림 | +| `포장_물넘이_겹침` | pavement_concrete | 포장 구간이 물넘이를 품으면 나눔 · 끝만 걸치면 저장 멈춤 | + +- 정의에서 붙이고 뗌 = `종류.{키}.동작` · 새 동작은 코딩 뒤 목록에 이름을 더함. + +## 7. 판 · 이력 + +- 저장 = 읽어 간 `판` 을 같이 보냄 → 같으면 앞 판을 `구조물정의_이력/판_N.json` 으로 남기고 새 판 = N + 1. +- 검사(틀리면 422 · 안 씀) = 칸 꼴 · 종류 키 · 옵션 키 겹침 · 고르기 선택지 있음 · 구조물군 있음 · 표시 · 그룹 · 배치 · 종류 덧칸 · 규칙 대상 있음. +- 판 1 = 옛 `B05_Profile_Structure_Types.json`(37종 · 옵션 235) + `구조물옵션표시.json` + `구조물옵션그룹.json` 을 키 그대로 옮김. diff --git a/resources/master_template/구조물옵션그룹.json b/resources/master_template/구조물옵션그룹.json deleted file mode 100644 index c06d97035..000000000 --- a/resources/master_template/구조물옵션그룹.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pipe": [ - { - "id": "grv6lny", - "이름": "관", - "옵션": [ - "pipe_kind", - "pipe_diameter_mm", - "inlet_type" - ] - }, - { - "id": "gnbf9m9", - "이름": "관 보호공(유입)", - "옵션": [] - }, - { - "id": "g9lq9uk", - "이름": "관 보호공(유출)", - "옵션": [] - } - ] -} diff --git a/resources/master_template/구조물옵션표시.json b/resources/master_template/구조물옵션표시.json deleted file mode 100644 index 0967ef424..000000000 --- a/resources/master_template/구조물옵션표시.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/resources/master_template/구조물정의.json similarity index 83% rename from B05_Profile/B05_Profile_Structure_Types.json rename to resources/master_template/구조물정의.json index 43d980283..feb15c0bf 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/resources/master_template/구조물정의.json @@ -1,6 +1,26 @@ { + "판": 1, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, "types": [ { "type_id": "pipe", @@ -12,13 +32,23 @@ "color": "#4f8ef7", "abbr": "배수관" }, - "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], "options": [ { "key": "pipe_kind", "label": "관종", "input": "select", - "choices": ["흄관", "VR관", "파형강관"], + "choices": [ + "흄관", + "VR관", + "파형강관" + ], "default": "파형강관", "required": false }, @@ -26,7 +56,12 @@ "key": "pipe_diameter_mm", "label": "관경", "input": "select", - "choices": ["800", "1000", "1200", "1500"], + "choices": [ + "800", + "1000", + "1200", + "1500" + ], "unit": "mm", "default": "1000", "required": false @@ -35,7 +70,10 @@ "key": "inlet_type", "label": "유입구 구조", "input": "select", - "choices": ["기슭막이", "집수정"], + "choices": [ + "기슭막이", + "집수정" + ], "default": "기슭막이", "required": false }, @@ -43,7 +81,11 @@ "key": "wing_wall_type", "label": "관보호공 날개벽", "input": "select", - "choices": ["A-TYPE", "C-TYPE", "A-TYPE+집수정"], + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], "default": null, "required": false, "phase": "detail", @@ -53,7 +95,12 @@ "key": "inlet_basin_form", "label": "집수정 형식", "input": "select", - "choices": ["동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형"], + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], "default": null, "required": false, "phase": "detail" @@ -62,7 +109,12 @@ "key": "inlet_basin_material", "label": "집수정 형태", "input": "select", - "choices": ["콘크리트", "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)"], + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], "default": null, "required": false, "phase": "detail" @@ -98,7 +150,14 @@ "key": "inlet_revet_form", "label": "유입 기슭막이 형태", "input": "select", - "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], "default": "돌쌓기(찰)", "required": false, "phase": "detail" @@ -143,7 +202,9 @@ "key": "outlet_type", "label": "유출구 구조", "input": "select", - "choices": ["기슭막이"], + "choices": [ + "기슭막이" + ], "default": "기슭막이", "required": false }, @@ -151,7 +212,14 @@ "key": "outlet_revet_form", "label": "유출 기슭막이 형태", "input": "select", - "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], "default": "돌쌓기(메)", "required": false, "phase": "detail" @@ -196,7 +264,10 @@ "key": "revet_foundation", "label": "기슭막이 기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": false, "phase": "detail" @@ -213,7 +284,13 @@ "color": "#3b6fd4", "abbr": "암거" }, - "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], "options": [ { "key": "body_width_m", @@ -227,7 +304,10 @@ "key": "revet_foundation", "label": "기슭막이 기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": false, "phase": "detail" @@ -303,7 +383,10 @@ "key": "wing_in", "label": "날개벽(유입)", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": "있음", "required": false }, @@ -335,7 +418,10 @@ "key": "wing_out", "label": "날개벽(유출)", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": "있음", "required": false }, @@ -375,7 +461,13 @@ "color": "#2f9e8f", "abbr": "물넘이" }, - "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], "options": [ { "key": "ford_width_m", @@ -427,13 +519,23 @@ "color": "#28857a", "abbr": "세월교" }, - "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], "options": [ { "key": "pipe_kind", "label": "관종", "input": "select", - "choices": ["흄관", "VR관", "파형강관"], + "choices": [ + "흄관", + "VR관", + "파형강관" + ], "default": "파형강관", "required": false }, @@ -441,7 +543,12 @@ "key": "pipe_diameter_mm", "label": "관경", "input": "select", - "choices": ["800", "1000", "1200", "1500"], + "choices": [ + "800", + "1000", + "1200", + "1500" + ], "unit": "mm", "default": "1000", "required": false @@ -468,7 +575,10 @@ "key": "wing_in", "label": "날개벽(유입)", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": "있음", "required": false }, @@ -500,7 +610,10 @@ "key": "wing_out", "label": "날개벽(유출)", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": "있음", "required": false }, @@ -539,7 +652,12 @@ "color": "#5aa7e8", "abbr": "횡수로" }, - "drawing_views": ["plan", "profile", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], "options": [] }, { @@ -551,13 +669,21 @@ "color": "#6d9bd1", "abbr": "개거" }, - "drawing_views": ["plan", "profile", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "ditch_spec", "label": "규격", "input": "select", - "choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"], + "choices": [ + "콘크리트 개거 150×200", + "L형수로 H=0.2" + ], "default": null, "required": false, "phase": "detail", @@ -585,13 +711,22 @@ "color": "#2eaadc", "abbr": "측구" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "form", "label": "형식", "input": "select", - "choices": ["일반형(제형)", "L형", "U형", "J형"], + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], "default": null, "required": true, "phase": "detail" @@ -615,13 +750,21 @@ "color": "#2e8bdc", "abbr": "산마루" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "material", "label": "재료", "input": "select", - "choices": ["콘크리트", "돌", "떼"], + "choices": [ + "콘크리트", + "돌", + "떼" + ], "default": null, "required": true, "phase": "detail" @@ -638,7 +781,10 @@ "color": "#2ec5dc", "abbr": "소단구" }, - "drawing_views": ["cross_section", "quantity"], + "drawing_views": [ + "cross_section", + "quantity" + ], "options": [ { "key": "length_m", @@ -706,13 +852,24 @@ "color": "#1f9bb8", "abbr": "도수로" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "material", "label": "재료", "input": "select", - "choices": ["떼", "돌", "콘크리트", "블록판", "벤치플륨관", "반원관"], + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], "default": null, "required": true, "phase": "detail" @@ -721,7 +878,11 @@ "key": "section", "label": "단면형", "input": "select", - "choices": ["반달꼴", "U자형", "사다리꼴"], + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], "default": null, "required": true, "phase": "detail" @@ -738,13 +899,19 @@ "color": "#28b1a1", "abbr": "사면로" }, - "drawing_views": ["cross_section", "quantity"], + "drawing_views": [ + "cross_section", + "quantity" + ], "options": [ { "key": "material", "label": "재료", "input": "select", - "choices": ["떼", "돌"], + "choices": [ + "떼", + "돌" + ], "default": null, "required": true, "phase": "detail" @@ -761,7 +928,11 @@ "color": "#1f7fb8", "abbr": "맹암거" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "note", @@ -780,7 +951,12 @@ "color": "#e07a2e", "abbr": "옹벽" }, - "drawing_views": ["profile", "cross_section", "detail", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], "options": [ { "key": "height_m", @@ -824,7 +1000,13 @@ "key": "form", "label": "형식", "input": "select", - "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"], + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], "default": null, "required": true, "phase": "detail" @@ -833,7 +1015,11 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, @@ -868,7 +1054,11 @@ "color": "#d0692a", "abbr": "찰쌓기" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "height_m", @@ -931,7 +1121,15 @@ "key": "back_len_cm", "label": "뒷길이", "input": "select", - "choices": ["25", "30", "35", "45", "55", "60", "75"], + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], "default": null, "required": true, "phase": "detail" @@ -940,7 +1138,12 @@ "key": "stone_kind", "label": "돌 종류", "input": "select", - "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "default": null, "required": true, "phase": "detail" @@ -949,7 +1152,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail" @@ -958,7 +1164,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -968,7 +1177,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -987,7 +1199,10 @@ "key": "face_role", "label": "성토/절토", "input": "select", - "choices": ["성토", "절토"], + "choices": [ + "성토", + "절토" + ], "default": null, "required": false, "phase": "detail", @@ -997,7 +1212,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1006,7 +1224,11 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, @@ -1034,7 +1256,10 @@ "key": "blinding_concrete", "label": "버림 콘크리트", "input": "select", - "choices": ["넣음", "안 넣음"], + "choices": [ + "넣음", + "안 넣음" + ], "default": "넣음", "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", "required": false, @@ -1052,7 +1277,11 @@ "color": "#c25d2c", "abbr": "메쌓기" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "height_m", @@ -1117,7 +1346,15 @@ "key": "back_len_cm", "label": "뒷길이", "input": "select", - "choices": ["25", "30", "35", "45", "55", "60", "75"], + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], "default": null, "required": true, "phase": "detail" @@ -1126,7 +1363,12 @@ "key": "stone_kind", "label": "돌 종류", "input": "select", - "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "default": null, "required": true, "phase": "detail" @@ -1135,7 +1377,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail" @@ -1144,7 +1389,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -1154,7 +1402,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -1173,7 +1424,10 @@ "key": "face_role", "label": "성토/절토", "input": "select", - "choices": ["성토", "절토"], + "choices": [ + "성토", + "절토" + ], "default": null, "required": false, "phase": "detail", @@ -1183,7 +1437,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1192,7 +1449,11 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, @@ -1220,7 +1481,10 @@ "key": "blinding_concrete", "label": "버림 콘크리트", "input": "select", - "choices": ["넣음", "안 넣음"], + "choices": [ + "넣음", + "안 넣음" + ], "default": "넣음", "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", "required": false, @@ -1238,7 +1502,11 @@ "color": "#b8742e", "abbr": "흙막이" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "height_m", @@ -1298,7 +1566,11 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, @@ -1358,7 +1630,15 @@ "key": "back_len_cm", "label": "뒷길이", "input": "select", - "choices": ["25", "30", "35", "45", "55", "60", "75"], + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], "default": null, "required": false, "phase": "detail", @@ -1369,7 +1649,12 @@ "key": "stone_kind", "label": "돌 종류", "input": "select", - "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "default": null, "required": false, "phase": "detail", @@ -1380,7 +1665,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail", @@ -1391,7 +1679,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -1402,7 +1693,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -1423,7 +1717,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1461,7 +1758,11 @@ "color": "#a86a2a", "abbr": "큰돌" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "height_m", @@ -1504,7 +1805,11 @@ "key": "stone_cm", "label": "돌규격", "input": "select", - "choices": ["40~60", "60~80", "80~100"], + "choices": [ + "40~60", + "60~80", + "80~100" + ], "default": null, "required": true, "phase": "detail" @@ -1513,7 +1818,10 @@ "key": "bond", "label": "쌓기 방식", "input": "select", - "choices": ["메쌓기", "찰쌓기"], + "choices": [ + "메쌓기", + "찰쌓기" + ], "default": null, "required": true, "phase": "detail" @@ -1522,7 +1830,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail", @@ -1532,7 +1843,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -1543,7 +1857,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -1563,7 +1880,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1572,7 +1892,11 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, @@ -1602,7 +1926,10 @@ "key": "blinding_concrete", "label": "버림 콘크리트", "input": "select", - "choices": ["넣음", "안 넣음"], + "choices": [ + "넣음", + "안 넣음" + ], "default": "넣음", "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", "required": false, @@ -1620,7 +1947,11 @@ "color": "#7f9a63", "abbr": "소단" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "length_m", @@ -1687,13 +2018,25 @@ "color": "#7a52c7", "abbr": "골막이" }, - "drawing_views": ["plan", "profile", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], "options": [ { "key": "form", "label": "형식", "input": "select", - "choices": ["돌", "돌망태", "콘크리트", "통나무", "바자", "흙"], + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], "default": null, "required": true, "phase": "detail" @@ -1702,7 +2045,10 @@ "key": "spillway", "label": "방수로", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": null, "required": true, "phase": "detail" @@ -1711,7 +2057,12 @@ "key": "stone_kind", "label": "돌 종류", "input": "select", - "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "default": null, "required": true, "phase": "detail" @@ -1742,7 +2093,15 @@ "key": "back_len_cm", "label": "뒷길이 ℓ3", "input": "select", - "choices": ["25", "30", "35", "45", "55", "60", "75"], + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], "default": null, "required": true, "phase": "detail" @@ -1751,7 +2110,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail", @@ -1761,7 +2123,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -1772,7 +2137,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -1791,7 +2159,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1859,13 +2230,20 @@ "color": "#7c5f3b", "abbr": "바닥" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "form", "label": "형태", "input": "select", - "choices": ["돌붙임(찰)", "돌붙임(메)"], + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], "default": "돌붙임(메)", "required": false, "phase": "b05" @@ -1874,7 +2252,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail", @@ -1884,7 +2265,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -1895,7 +2279,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -1916,7 +2303,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -1950,7 +2340,12 @@ "color": "#6b46b8", "abbr": "사방댐" }, - "drawing_views": ["plan", "profile", "detail", "quantity"], + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], "options": [ { "key": "height_m", @@ -1973,13 +2368,21 @@ "color": "#9b6bdc", "abbr": "기슭" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "side", "label": "설치 측", "input": "select", - "choices": ["양쪽", "좌", "우"], + "choices": [ + "양쪽", + "좌", + "우" + ], "default": "양쪽", "required": false, "phase": "b05", @@ -2019,7 +2422,14 @@ "key": "form", "label": "형태", "input": "select", - "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], "default": "돌쌓기(메)", "required": false, "phase": "b05" @@ -2048,7 +2458,15 @@ "key": "back_len_cm", "label": "뒷길이", "input": "select", - "choices": ["25", "30", "35", "45", "55", "60", "75"], + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], "default": null, "required": false, "phase": "detail", @@ -2058,7 +2476,12 @@ "key": "stone_kind", "label": "돌 종류", "input": "select", - "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "default": null, "required": false, "phase": "detail", @@ -2068,7 +2491,10 @@ "key": "stone_supply", "label": "조달", "input": "select", - "choices": ["채집", "구입"], + "choices": [ + "채집", + "구입" + ], "default": "채집", "required": false, "phase": "detail" @@ -2077,7 +2503,10 @@ "key": "stone_coeff_basis", "label": "야면석 계수", "input": "select", - "choices": ["품셈", "실무 관행"], + "choices": [ + "품셈", + "실무 관행" + ], "default": null, "required": false, "phase": "detail", @@ -2087,7 +2516,10 @@ "key": "fill_concrete_mpa", "label": "채움 강도", "input": "select", - "choices": ["180", "210"], + "choices": [ + "180", + "210" + ], "default": null, "required": false, "phase": "detail", @@ -2106,7 +2538,10 @@ "key": "face_role", "label": "성토/절토", "input": "select", - "choices": ["성토", "절토"], + "choices": [ + "성토", + "절토" + ], "default": null, "required": false, "phase": "detail", @@ -2116,7 +2551,10 @@ "key": "foundation", "label": "기초", "input": "select", - "choices": ["기초유", "기초버림"], + "choices": [ + "기초유", + "기초버림" + ], "default": null, "required": true, "phase": "detail" @@ -2182,7 +2620,10 @@ "key": "blinding_concrete", "label": "버림 콘크리트", "input": "select", - "choices": ["넣음", "안 넣음"], + "choices": [ + "넣음", + "안 넣음" + ], "default": "넣음", "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", "required": false, @@ -2200,7 +2641,11 @@ "color": "#2fa84f", "abbr": "대피소" }, - "drawing_views": ["plan", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], "options": [ { "key": "width_m", @@ -2229,7 +2674,11 @@ "color": "#268f43", "abbr": "작업장" }, - "drawing_views": ["plan", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], "options": [ { "key": "width_m", @@ -2256,7 +2705,10 @@ "color": "#37b95a", "abbr": "차돌림" }, - "drawing_views": ["plan", "quantity"], + "drawing_views": [ + "plan", + "quantity" + ], "options": [ { "key": "width_m", @@ -2276,13 +2728,19 @@ "color": "#43c76b", "abbr": "반사경" }, - "drawing_views": ["plan"], + "drawing_views": [ + "plan" + ], "options": [ { "key": "facing", "label": "방향", "input": "select", - "choices": ["좌", "우", "양면"], + "choices": [ + "좌", + "우", + "양면" + ], "default": null, "required": true, "phase": "detail" @@ -2298,13 +2756,21 @@ "color": "#1f9e46", "abbr": "가드" }, - "drawing_views": ["plan", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], "options": [ { "key": "kind", "label": "종류", "input": "select", - "choices": ["가드레일", "경계석", "위험표지"], + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], "default": null, "required": true, "phase": "detail" @@ -2320,7 +2786,10 @@ "color": "#53a832", "abbr": "번호판" }, - "drawing_views": ["plan", "detail"], + "drawing_views": [ + "plan", + "detail" + ], "options": [] }, { @@ -2332,7 +2801,10 @@ "color": "#3f9e2e", "abbr": "차단기" }, - "drawing_views": ["plan", "detail"], + "drawing_views": [ + "plan", + "detail" + ], "options": [] }, { @@ -2344,7 +2816,9 @@ "color": "#66b344", "abbr": "준공" }, - "drawing_views": ["plan"], + "drawing_views": [ + "plan" + ], "options": [] }, { @@ -2356,13 +2830,21 @@ "color": "#8c9a2e", "abbr": "사토장" }, - "drawing_views": ["plan", "cross_section", "quantity"], + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], "options": [ { "key": "side", "label": "쌓는 쪽", "input": "select", - "choices": ["자동(성토 쪽)", "좌", "우"], + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], "default": "자동(성토 쪽)", "required": false, "phase": "b05", @@ -2416,7 +2898,10 @@ "color": "#7d8a2a", "abbr": "토취장" }, - "drawing_views": ["plan", "quantity"], + "drawing_views": [ + "plan", + "quantity" + ], "options": [ { "key": "area_m2", @@ -2448,13 +2933,19 @@ "color": "#3aa88f", "abbr": "동물길" }, - "drawing_views": ["plan", "cross_section"], + "drawing_views": [ + "plan", + "cross_section" + ], "options": [ { "key": "form", "label": "형식", "input": "select", - "choices": ["경사로", "자연형계단"], + "choices": [ + "경사로", + "자연형계단" + ], "default": null, "required": true, "phase": "detail" @@ -2471,7 +2962,10 @@ "color": "#4cb84c", "abbr": "녹화" }, - "drawing_views": ["cross_section", "quantity"], + "drawing_views": [ + "cross_section", + "quantity" + ], "options": [ { "key": "method", @@ -2498,7 +2992,11 @@ "key": "face", "label": "대상 면", "input": "select", - "choices": ["절토면", "성토면", "양쪽"], + "choices": [ + "절토면", + "성토면", + "양쪽" + ], "default": null, "required": true, "phase": "detail" @@ -2507,7 +3005,11 @@ "key": "cover_kind", "label": "덮개(비탈덮기)", "input": "select", - "choices": ["거적", "짚망", "방초·야자섬유매트"], + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], "default": null, "required": false, "phase": "detail", @@ -2527,7 +3029,17 @@ "key": "sod_grade", "label": "선떼 급수", "input": "select", - "choices": ["1급", "2급", "3급", "4급", "5급", "6급", "7급", "8급", "9급"], + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], "default": null, "required": false, "phase": "detail", @@ -2537,7 +3049,10 @@ "key": "sod_size", "label": "선떼 떼 크기", "input": "select", - "choices": ["대형 40×40", "소형 18×18"], + "choices": [ + "대형 40×40", + "소형 18×18" + ], "default": null, "required": false, "phase": "detail", @@ -2547,7 +3062,11 @@ "key": "cutting_soil", "label": "단끊기 토질", "input": "select", - "choices": ["보통토사", "경질ㆍ고사점토 및 자갈섞인 점토", "호박돌 섞인 토사"], + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], "default": null, "required": false, "phase": "detail", @@ -2564,7 +3083,11 @@ "color": "#8a8f98", "abbr": "포장" }, - "drawing_views": ["profile", "cross_section", "quantity"], + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], "options": [ { "key": "length_m", @@ -2630,7 +3153,10 @@ "key": "separation_sheet", "label": "비닐(깔기·양생)", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": null, "required": false, "phase": "detail", @@ -2640,7 +3166,10 @@ "key": "wire_mesh", "label": "철망", "input": "select", - "choices": ["있음", "없음"], + "choices": [ + "있음", + "없음" + ], "default": null, "required": false, "phase": "detail", @@ -2658,7 +3187,10 @@ "color": "#9aa0a8", "abbr": "쇄석" }, - "drawing_views": ["cross_section", "quantity"], + "drawing_views": [ + "cross_section", + "quantity" + ], "options": [ { "key": "thickness_cm", @@ -2680,7 +3212,9 @@ "color": "#98a2b3", "abbr": "기타" }, - "drawing_views": ["profile"], + "drawing_views": [ + "profile" + ], "options": [ { "key": "name", @@ -2690,5 +3224,71 @@ } ] } - ] + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ] + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": {}, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm", + "inlet_type" + ] + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [] + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [] + } + ] + }, + "배치": {}, + "규칙": [] } diff --git a/resources/tester/test_b05_structures_repository.py b/resources/tester/test_b05_structures_repository.py index 2f389d760..492ccad81 100644 --- a/resources/tester/test_b05_structures_repository.py +++ b/resources/tester/test_b05_structures_repository.py @@ -192,10 +192,11 @@ def test_placement_mismatching_registry_is_rejected(project_root): save_structures(project_root, [wall_as_point], base_revision=0) -def test_undefined_option_key_is_rejected(project_root): - bad = _point(options={"form": "돌", "no_such_option": 1}) - with pytest.raises(ValueError, match="옵션"): - save_structures(project_root, [bad], base_revision=0) +def test_undefined_option_key_is_kept(project_root): + """지금 정의에 없는 키 = 지난 정의 값 — 오류 없이 그대로 남김(PLAN 48-1).""" + old = _point(options={"form": "돌", "no_such_option": 1}) + assert save_structures(project_root, [old], base_revision=0) == 1 + assert load_structures(project_root)[1][0].options["no_such_option"] == 1 def test_negative_number_option_is_rejected(project_root): diff --git a/resources/tester/test_b06_box_thickness_registry.py b/resources/tester/test_b06_box_thickness_registry.py index dfabe7746..198b6682a 100644 --- a/resources/tester/test_b06_box_thickness_registry.py +++ b/resources/tester/test_b06_box_thickness_registry.py @@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2] def _box_options() -> dict[str, dict]: registry = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) types = registry if isinstance(registry, list) else registry.get("types") or [] box = next(t for t in types if t["type_id"] == "box_culvert") diff --git a/resources/tester/test_b06_pipe_form_keys.py b/resources/tester/test_b06_pipe_form_keys.py index 2314a3120..059016c5a 100644 --- a/resources/tester/test_b06_pipe_form_keys.py +++ b/resources/tester/test_b06_pipe_form_keys.py @@ -32,7 +32,7 @@ QUANTITY_ONLY = { def _pipe_option_keys() -> list[str]: payload = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) types = payload if isinstance(payload, list) else payload["types"] pipe = next(item for item in types if item["type_id"] == "pipe") diff --git a/resources/tester/test_b08_dry_height_face_role.py b/resources/tester/test_b08_dry_height_face_role.py index 465478e13..280bcab9a 100644 --- a/resources/tester/test_b08_dry_height_face_role.py +++ b/resources/tester/test_b08_dry_height_face_role.py @@ -20,7 +20,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 REGISTRY = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_b08_option_keys.py b/resources/tester/test_b08_option_keys.py index ba9985dcf..d9847d684 100644 --- a/resources/tester/test_b08_option_keys.py +++ b/resources/tester/test_b08_option_keys.py @@ -30,7 +30,7 @@ KNOWN_ABSENT: dict[str, str] = {} def _registry_keys() -> set[str]: payload = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) types = payload if isinstance(payload, list) else (payload.get("types") or []) return {option["key"] for item in types for option in item.get("options") or []} diff --git a/resources/tester/test_b08_preparation.py b/resources/tester/test_b08_preparation.py index 9d6ddb716..a77b15bb3 100644 --- a/resources/tester/test_b08_preparation.py +++ b/resources/tester/test_b08_preparation.py @@ -90,7 +90,7 @@ def test_사방_종류_이름이_레지스트리와_맞을것() -> None: import json registry = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) types = registry if isinstance(registry, list) else (registry.get("types") or []) known = {t["type_id"] for t in types} diff --git a/resources/tester/test_b08_structure_table_holes.py b/resources/tester/test_b08_structure_table_holes.py index 5b6363f23..e38067bd0 100644 --- a/resources/tester/test_b08_structure_table_holes.py +++ b/resources/tester/test_b08_structure_table_holes.py @@ -68,7 +68,7 @@ def test_바닥막이는_제곱미터당() -> None: def test_개거_L형수로는_등록부_글자로_선다() -> None: registry = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) choices = next( option["choices"] diff --git a/resources/tester/test_b08_wing_wall.py b/resources/tester/test_b08_wing_wall.py index a8d0bd619..c3e4cfadf 100644 --- a/resources/tester/test_b08_wing_wall.py +++ b/resources/tester/test_b08_wing_wall.py @@ -29,7 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 def _registry_choices() -> list[str]: payload = json.loads( - (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") ) types = payload if isinstance(payload, list) else payload["types"] pipe = next(item for item in types if item["type_id"] == "pipe") diff --git a/resources/tester/test_m02_structure_option_groups.py b/resources/tester/test_m02_structure_option_groups.py index 660fc9c6c..7e3ca8312 100644 --- a/resources/tester/test_m02_structure_option_groups.py +++ b/resources/tester/test_m02_structure_option_groups.py @@ -1,12 +1,11 @@ -"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2). +"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · 정의 「그룹」 칸 · PLAN 46-2). 못박는 것 - - 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`. + - 「그룹」 이 `{}` 면 그룹 없음 · `/structure-types` 종류마다 `groups: []`. - 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감. - 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부). - 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 · 거절되면 안 씀. - - 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500. - 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드). """ @@ -19,10 +18,10 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from B05_Profile import B05_Profile_Structures_Schema as schema from B05_Profile.B05_Profile_Structures_Router import router as b05_router from B05_Profile.B05_Profile_Structures_Schema import load_structure_types from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router ROOT = Path(__file__).resolve().parents[2] @@ -32,7 +31,12 @@ TYPES = "/api/projects/structure-types" @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: + """정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1).""" monkeypatch.setattr(store, "FOLDER", tmp_path) + doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8")) + copy = tmp_path / "구조물정의.json" + copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy)) app = FastAPI() app.include_router(m02_router) app.include_router(b05_router) @@ -48,11 +52,9 @@ def _groups(client: TestClient) -> dict[str, list[dict]]: return {t["type_id"]: t["groups"] for t in client.get(TYPES).json()["types"]} -def test_시드는_빈_표_없으면_그룹_없음(client) -> None: - seed = ROOT / "resources/master_template/구조물옵션그룹.json" - assert json.loads(seed.read_text("utf-8")) == {} +def test_빈_그룹_칸은_그룹_없음(client) -> None: got = client.get(URL).json() - assert got["판"] == "" and got["그룹"] == {} + assert got["판"] == "1" and got["그룹"] == {} assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도 assert all(v == [] for v in _groups(client).values()) @@ -67,7 +69,7 @@ def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) -> ], empty: [{"id": "gx", "이름": "부속", "옵션": []}], } - res = client.put(URL, json={"판": "", "그룹": body}) + res = client.put(URL, json={"판": "1", "그룹": body}) assert res.status_code == 200, res.text assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움 seen = _groups(client) @@ -92,20 +94,14 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None: {tid: [ok, {"id": "g2", "이름": "나", "옵션": [a]}]}, # 옵션 두 그룹 {tid: {"g1": ok}}, ): - assert client.put(URL, json={"판": "", "그룹": bad}).status_code == 422, bad - assert not (store.FOLDER / groups.FILE).exists() # 거절되면 안 씀 - v1 = client.put(URL, json={"판": "", "그룹": {tid: [ok]}}).json()["판"] + assert client.put(URL, json={"판": "1", "그룹": bad}).status_code == 422, bad + assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + v1 = client.put(URL, json={"판": "1", "그룹": {tid: [ok]}}).json()["판"] assert client.put(URL, json={"판": "낡음", "그룹": {}}).status_code == 409 res = client.put(URL, json={"판": v1, "그룹": {}}) assert res.status_code == 200 and res.json()["그룹"] == {} -def test_파일이_깨져도_폼_응답은_그룹_없음(client) -> None: - (store.FOLDER / groups.FILE).write_text("{깨짐", encoding="utf-8") - assert all(v == [] for v in _groups(client).values()) - assert client.get(URL).status_code == 500 - - def test_좌측_옵션_보이기는_시스템_관리자만() -> None: side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") assert "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 670c84f23..971a94575 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -1,10 +1,9 @@ -"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`). +"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정의 「표시」 칸 · PLAN 48-1). 못박는 것 - - 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible. + - 「표시」 가 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible. - 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜. - 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409. - - 파일이 깨져도 폼 응답은 모두 보임(죽지 않음). """ from __future__ import annotations @@ -16,10 +15,10 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from B05_Profile import B05_Profile_Structures_Schema as schema from B05_Profile.B05_Profile_Structures_Router import router as b05_router from B05_Profile.B05_Profile_Structures_Schema import load_structure_types from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete.StructOptions import M02_SO_Options as opt from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router ROOT = Path(__file__).resolve().parents[2] @@ -33,7 +32,12 @@ def structure_type(type_id): @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: + """정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1).""" monkeypatch.setattr(store, "FOLDER", tmp_path) + doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8")) + copy = tmp_path / "구조물정의.json" + copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy)) app = FastAPI() app.include_router(m02_router) app.include_router(b05_router) @@ -51,13 +55,9 @@ def _visible(client: TestClient) -> dict[str, dict[str, bool]]: return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types} -def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None: - assert ( - json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8")) - == {} - ) +def test_빈_표시는_모두_보임(client) -> None: got = client.get(URL).json() - assert got["판"] == "" and got["표시"] == {} # 파일 없음 + assert got["판"] == "1" and got["표시"] == {} assert all(all(v.values()) for v in _visible(client).values()) assert client.get(TYPES).json()["schema_version"] == 1 @@ -65,7 +65,7 @@ def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None: def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None: tid, off, on = _pick() before = client.get(TYPES).json()["types"] - res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}}) + res = client.put(URL, json={"판": "1", "표시": {tid: {off: False, on: True}}}) assert res.status_code == 200, res.text assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김 seen = _visible(client) @@ -83,10 +83,10 @@ def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None: tid, off, _ = _pick() - v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"] + v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"] res = client.put(URL, json={"판": v1, "표시": {}}) assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1 - assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {} + assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["표시"] == {} assert all(all(v.values()) for v in _visible(client).values()) @@ -98,21 +98,15 @@ def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> {tid: {off: "no"}}, {tid: [off]}, ): - assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad - assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양) - assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀 - v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"] + assert client.put(URL, json={"판": "1", "표시": bad}).status_code == 422, bad + assert client.put(URL, json={"판": "1", "표시": []}).status_code == 422 # 묶음 아님(요청 모양) + assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"] assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409 - assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기 + assert client.put(URL, json={"판": "1", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기 assert client.get(URL).json()["판"] == v1 -def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None: - (store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8") - assert all(all(v.values()) for v in _visible(client).values()) - assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림 - - def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None: side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") assert "section(options.title, options.body)" in side @@ -165,7 +159,7 @@ def test_숨긴_옵션은_필수_아님_저장이_안_막힘(client, tmp_path, m root = str(tmp_path / "project") with pytest.raises(ValueError, match="필수"): repo.save_structures(root, [item({})], base_revision=0) # 보임 = 필수 그대로 - (store.FOLDER / opt.FILE).write_text('{"fake_hide": {"a": false}}', encoding="utf-8") + monkeypatch.setattr(repo, "hidden_keys", lambda: {"fake_hide": {"a"}}) with pytest.raises(ValueError, match="필수"): # a 는 숨겨 통과 · b 는 여전히 필수 repo.save_structures(root, [item({})], base_revision=0) assert (