From 44c6136b9112877f0e2d77910fad357853a0d010 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:00:45 +0900 Subject: [PATCH 1/3] auto: 2026-10-05 16:00 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index eeadef7f7..9569cdcc8 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -48,3 +48,23 @@ 4. 구조물 조합의 단가산출 상세화면에도 원문보기 우측 패널이 있어야함. 우측 패널은 단가산출 로직을 선택할 경우에 원문을 보여주는 기능이며 패널을 접었다 펼수 있어야함. 5. 원문 패널 추가에 따라 현재 단가산출 로직 항목을 선택하면 옵션창이 보이나 선택하면 하이라이트를 하고 원문을 보일수 있게 함. 그리고 옵션설정에 대한 조정은 올리기, 내리기, 빼기 버튼의 이름을 위로, 아래로, 삭제로 변경하고 아래로 와 삭제 사이에 신규 버튼(이름 : 옵션)을 추가하여 선택할 수 있게 변경. 버튼 사이간격에 대해 템플릿화하여 적용. 6. 구조물 조합의 면적산출 우측패널에 별도의 도면 패널 추가(기능은 4번 요청 확인) + +## 구조물 옵션 정의 페이지 (10-5 · 원문 그대로) + +이제부터 다시 계획서를 세울 예정임. +1. 목표 : 현재 구조물 옵션 보이기의 컨테이너 항목을 기준으로 프로젝트 내부에 사용하는 구조물의 옵션 항목 및 제어를 설정하는 페이지를 궁성 +2. 상세 내용 + - 현재 M02의 구조물 옵션보이기 항목으로 구조물군과 구조물 종류를 설정하고 이름을 변경할 수 있게 반영 + - 구조물 종류를 선택시 우측에 해당항목의 일위대가 그룹을 지정하고 변수를 편입할수 있게 반영(기반영), 추가로 배치 순서를 조절할 수 있게, 행열의 크기 제어도 같이 + - 구조물 종류의 특정 옵션의 옵션 값 항목을 지정할 수 있게 반영 + - 구조물 종류의 최상위에 기본값인 기준 측점을 표현할건지에 대한 기능 + - 구조물의 길이, 높이, 기준측점 전/후 값에 대한 옵션 사용여부 및 어떤걸 제어하는지에 대한 결정 + - 구조물 옵션과 값들의 이름 변경기능 + - 옵션들은 상위에 따라 하위 옵션을 변경할 수 있게 반영 +3. 현재 B05와 B06의 방대한 변경이 필요하니 순차적으로 진행, 가자먼저 작업할 내용은 B05와 B06에 좌측 패널의 구조물 배치 컨테이너에 보일 항목을 반만들수 있게 반영하고 선택에 따라 변경되는 변수 값과 제어는 별도로 작업 할 예정. 일단 방향성에 대해 검토해보고 대화부터 하자. 내가 원하는 방향은 결국 내가 프로그래머가 아니라서 나중에 항목을 추가하거나 옵션을 변경하였을때 프론트엔드에 기능을 구현하고 변경하고 싶은것임. + +## 원문 보기 패널 (10-5 · 원문 그대로) + +계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서. + +그리고 우측 패널의 내용은 메인 화면의 스크롤과 별개로 동작하게 반영 From bf1fa5081793b253406c2e55d2113cd919211ebc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:09:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(M02):=2048-1=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A0=95=EC=9D=98=20=ED=95=9C=20=EB=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=95=EC=9D=98=20=ED=8B=80=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=C2=B7=20=ED=8C=90=201=20=EC=98=AE=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BD=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 ( From c6d78f40699a95103000726e25850ca4ef319e2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:14:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(M02):=2048-1=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A0=95=EC=9D=98=20=EC=8B=9C=ED=97=98=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=99=95=EC=9D=B8=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판 1 옮김 37종 · 옵션 235 · 검사 통과 · 읽기 누구나 · 쓰기 403 · 판 +1 · 이력 판_N · 낡은 판 409 - 검사 422 = 키 겹침 · 꼴 · 규칙 대상 · 배치 · 종류 덧칸 · 표시 · 그룹 형상 · 모르는 칸(안 씀) - 프로젝트 저장 정의 판 · 없는 키 값 보존 · ui_checks 48-1_구조물정의_읽기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../tester/test_m02_structure_definition.py | 158 ++++++++++++++++++ .../tester/ui_checks/48-1_구조물정의_읽기.js | 60 +++++++ 2 files changed, 218 insertions(+) create mode 100644 resources/tester/test_m02_structure_definition.py create mode 100644 resources/tester/ui_checks/48-1_구조물정의_읽기.js diff --git a/resources/tester/test_m02_structure_definition.py b/resources/tester/test_m02_structure_definition.py new file mode 100644 index 000000000..d4df44197 --- /dev/null +++ b/resources/tester/test_m02_structure_definition.py @@ -0,0 +1,158 @@ +"""M02 구조물 정의 한 벌(`/api/m02/structure-definition` · PLAN 48-1). + +못박는 것 + - 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과. + - 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403). + - 저장마다 판 +1 · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). + - 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상. + - 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +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_Repository import load_structures, save_structures +from B05_Profile.B05_Profile_Structures_Router import router as b05_router +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_structure_types +from common_util.common_util_auth import verify_session +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + +URL = "/api/m02/structure-definition" +TYPES = "/api/projects/structure-types" +ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} +USER = {"role": "USER", "user_id": 2, "company_id": 1} + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + """정의 사본 — 마스터 정본은 안 씀.""" + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _client(session: dict) -> TestClient: + app = FastAPI() + app.include_router(read_router) + app.include_router(b05_router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: + doc = definition.read() + assert doc["판"] == 1 and len(doc["types"]) == 37 + assert sum(len(t["options"]) for t in doc["types"]) == 235 + assert len(load_structure_types()) == 37 + assert sum(len(t.options) for t in load_structure_types()) == 235 + assert definition.check(doc) == [] + assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"] + assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"]) + + +def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None: + user = _client(USER) + got = user.get(URL) + assert got.status_code == 200 and got.json()["판"] == 1 + assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀 + + +def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + tid = doc["types"][0]["type_id"] + doc["types"][0]["name"] = "이름 바꿈" + res = admin.put(URL, json={"판": 1, "정의": doc}) + assert res.status_code == 200, res.text + assert res.json()["판"] == 2 + old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8")) + assert old["판"] == 1 and old["types"][0]["name"] != "이름 바꿈" + seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]} + assert seen[tid]["name"] == "이름 바꿈" and len(seen) == 37 + assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판 + assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3 + assert (copy.parent / "구조물정의_이력" / "판_2.json").is_file() + + +def _rule(doc: dict) -> tuple[str, dict, dict]: + """고르기 옵션 둘 이상 가진 종류 — (종류 키, 상위 옵션, 하위 옵션).""" + for t in doc["types"]: + picks = [o for o in t["options"] if o["input"] == "select"] + if len(picks) >= 2: + return t["type_id"], picks[0], picks[1] + raise AssertionError("고르기 둘인 종류 없음") + + +def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: + admin = _client(ADMIN) + base = admin.get(URL).json() + tid, up, down = _rule(base) + good = { + "종류": tid, + "만약": {"옵션": up["key"], "값들": [up["choices"][0]]}, + "그러면": {"옵션": down["key"], "선택지": down["choices"][:1], "보이기": True}, + } + assert definition.check({**base, "규칙": [good]}) == [] + + def bad(**change) -> dict: + return json.loads(json.dumps({**base, **change})) + + dup = bad() + dup["types"].append(dup["types"][0]) # 종류 키 겹침 + opt_dup = bad() + opt_dup["types"][0]["options"].append(opt_dup["types"][0]["options"][0]) # 옵션 키 겹침 + shape = bad() + shape["types"][0]["options"][0]["input"] = "날짜" # 없는 꼴 + cases = [ + dup, + opt_dup, + shape, + bad(규칙=[{**good, "그러면": {**good["그러면"], "옵션": "없는키"}}]), # 대상 없음 + bad(규칙=[{**good, "종류": "없는종류"}]), + bad(규칙=[{**good, "만약": {"옵션": up["key"], "값들": ["없는값"]}}]), + bad(규칙=[{**good, "그러면": {"옵션": down["key"], "선택지": ["없는값"]}}]), + bad(규칙=[{**good, "그러면": {"옵션": down["key"]}}]), # 효과 없음 + bad(배치={tid: {up["key"]: {"줄": 0, "칸": 1}}}), + bad(배치={tid: {"없는키": {"줄": 1}}}), + bad(종류={tid: {"동작": ["없는동작"]}}), + bad(종류={"없는종류": {"기준측점": False}}), + bad(표시={tid: {"없는키": False}}), + bad( + 그룹={"pipe": [{"id": "g1", "이름": "가", "옵션": [], "형상": {"폭": {"받기": True}}}]} + ), + bad(모르는칸=1), + ] + for i, doc in enumerate(cases): + res = admin.put(URL, json={"판": 1, "정의": doc}) + assert res.status_code == 422, (i, res.text) + assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + assert not (copy.parent / "구조물정의_이력").exists() + shaped = bad(종류={tid: {"기준측점": False}}) + shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}} + assert admin.put(URL, json={"판": 1, "정의": shaped}).status_code == 200 + + +def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None: + root = str(tmp_path / "project") + item = StructureInstance.model_validate( + { + "type_id": "mirror", + "placement": "point", + "chainage_m": 10.0, + "options": {"지난정의키": "옛값"}, + } + ) + assert save_structures(root, [item], base_revision=0) == 1 + raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8")) + assert raw["definition_version"] == 1 + assert load_structures(root)[1][0].options["지난정의키"] == "옛값" diff --git a/resources/tester/ui_checks/48-1_구조물정의_읽기.js b/resources/tester/ui_checks/48-1_구조물정의_읽기.js new file mode 100644 index 000000000..306517a2f --- /dev/null +++ b/resources/tester/ui_checks/48-1_구조물정의_읽기.js @@ -0,0 +1,60 @@ +/* 48-1 — 구조물 정의 한 벌(resources/master_template/구조물정의.json)로 옮긴 뒤 화면 변화 0 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자 · 저장 안 함 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition) + * - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] } + * (GET /api/projects/structure-types — 옛 꼴 그대로) + * - 좌측 「구조물 옵션 보이기」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 옛 화면 그대로) + * · pipe 단추에 그룹 표지 「3」 + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const get = async (url) => { + const r = await fetch(url, { credentials: "include" }); + return r.ok ? r.json() : { status: r.status }; + }; + const out = {}; + const def = await get("/api/m02/structure-definition"); + if (!def.types) return JSON.stringify({ error: "정의 못 읽음", def }); + out.def = { + 판: def.판, + types: def.types.length, + options: def.types.reduce((n, t) => n + t.options.length, 0), + }; + const st = await get("/api/projects/structure-types"); + const pipe = st.types.find((t) => t.type_id === "pipe"); + out.types = { + types: st.types.length, + options: st.types.reduce((n, t) => n + t.options.length, 0), + visibleAll: st.types.every((t) => t.options.every((o) => typeof o.visible === "boolean")), + pipeGroups: pipe.groups.map((g) => g.name), + }; + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ ...out, error: "옵션 보이기 줄 없음" }); + row.click(); + await until(() => $$("[data-options-kind]").length); + const kinds = $$("[data-options-kind]"); + out.screen = { + kinds: kinds.length, + withOptions: def.types.filter((t) => t.options.length).length, + pipeTag: $('[data-options-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null, + }; + out.ok = + out.def.types === 37 && + out.def.options === 235 && + out.types.types === 37 && + out.types.options === 235 && + out.types.pipeGroups.length === 3; + return JSON.stringify(out); +})();