From 9b1fe9cfa943a04bdc324cf5c4c071e4134e5364 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 15:55:16 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore(plan):=2010-5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=8C=A8=EB=84=90=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index eeadef7f7..ce90f03b7 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -48,3 +48,21 @@ 4. 구조물 조합의 단가산출 상세화면에도 원문보기 우측 패널이 있어야함. 우측 패널은 단가산출 로직을 선택할 경우에 원문을 보여주는 기능이며 패널을 접었다 펼수 있어야함. 5. 원문 패널 추가에 따라 현재 단가산출 로직 항목을 선택하면 옵션창이 보이나 선택하면 하이라이트를 하고 원문을 보일수 있게 함. 그리고 옵션설정에 대한 조정은 올리기, 내리기, 빼기 버튼의 이름을 위로, 아래로, 삭제로 변경하고 아래로 와 삭제 사이에 신규 버튼(이름 : 옵션)을 추가하여 선택할 수 있게 변경. 버튼 사이간격에 대해 템플릿화하여 적용. 6. 구조물 조합의 면적산출 우측패널에 별도의 도면 패널 추가(기능은 4번 요청 확인) + +## 구조물 옵션 정의 페이지 (10-5 · 원문 그대로) + +이제부터 다시 계획서를 세울 예정임. +1. 목표 : 현재 구조물 옵션 보이기의 컨테이너 항목을 기준으로 프로젝트 내부에 사용하는 구조물의 옵션 항목 및 제어를 설정하는 페이지를 궁성 +2. 상세 내용 + - 현재 M02의 구조물 옵션보이기 항목으로 구조물군과 구조물 종류를 설정하고 이름을 변경할 수 있게 반영 + - 구조물 종류를 선택시 우측에 해당항목의 일위대가 그룹을 지정하고 변수를 편입할수 있게 반영(기반영), 추가로 배치 순서를 조절할 수 있게, 행열의 크기 제어도 같이 + - 구조물 종류의 특정 옵션의 옵션 값 항목을 지정할 수 있게 반영 + - 구조물 종류의 최상위에 기본값인 기준 측점을 표현할건지에 대한 기능 + - 구조물의 길이, 높이, 기준측점 전/후 값에 대한 옵션 사용여부 및 어떤걸 제어하는지에 대한 결정 + - 구조물 옵션과 값들의 이름 변경기능 + - 옵션들은 상위에 따라 하위 옵션을 변경할 수 있게 반영 +3. 현재 B05와 B06의 방대한 변경이 필요하니 순차적으로 진행, 가자먼저 작업할 내용은 B05와 B06에 좌측 패널의 구조물 배치 컨테이너에 보일 항목을 반만들수 있게 반영하고 선택에 따라 변경되는 변수 값과 제어는 별도로 작업 할 예정. 일단 방향성에 대해 검토해보고 대화부터 하자. 내가 원하는 방향은 결국 내가 프로그래머가 아니라서 나중에 항목을 추가하거나 옵션을 변경하였을때 프론트엔드에 기능을 구현하고 변경하고 싶은것임. + +## 원문 보기 패널 (10-5 · 원문 그대로) + +계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서. From 4600b9970cb685734f20dbff6515f2a5e2dcb8db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 15:55:37 +0900 Subject: [PATCH 02/20] =?UTF-8?q?chore(M02):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EA=B7=B8=EB=A3=B9(=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=A0=EC=B9=A8)=20=C2=B7=2049=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EB=8D=A7=20=EC=9A=94=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 2 ++ resources/master_template/구조물옵션그룹.json | 24 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index ce90f03b7..9569cdcc8 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -66,3 +66,5 @@ ## 원문 보기 패널 (10-5 · 원문 그대로) 계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서. + +그리고 우측 패널의 내용은 메인 화면의 스크롤과 별개로 동작하게 반영 diff --git a/resources/master_template/구조물옵션그룹.json b/resources/master_template/구조물옵션그룹.json index 0967ef424..c06d97035 100644 --- a/resources/master_template/구조물옵션그룹.json +++ b/resources/master_template/구조물옵션그룹.json @@ -1 +1,23 @@ -{} +{ + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm", + "inlet_type" + ] + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [] + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [] + } + ] +} From 44c6136b9112877f0e2d77910fad357853a0d010 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:00:45 +0900 Subject: [PATCH 03/20] 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 04/20] =?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 05/20] =?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); +})(); From 8ca2f1cd6ee2b5e180e6b28d1a68e14d337a50b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:15:10 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat(M02):=2048-2=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EA=B5=B0=20=C2=B7?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=C2=B7=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=C2=B7=20=EA=B7=B8=EB=A3=B9=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20=C2=B7=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=ED=8C=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만) - 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_UI_Side.ts | 4 +- .../StructOptions/M02_SO_Api.ts | 63 +--- .../StructOptions/M02_SO_Def.ts | 339 ++++++++++++++++++ .../StructOptions/M02_SO_Def_Layout.ts | 120 +++++++ .../StructOptions/M02_SO_Def_Model.ts | 268 ++++++++++++++ .../StructOptions/M02_SO_Def_Options.ts | 231 ++++++++++++ .../StructOptions/M02_SO_Def_Rules.ts | 234 ++++++++++++ .../StructOptions/M02_SO_Def_Ui.ts | 121 +++++++ .../StructOptions/M02_SO_Groups.ts | 60 ++-- .../StructOptions/M02_SO_Options.ts | 261 +------------- .../StructOptions/M02_SO_Style.css | 197 ++++++++++ .../StructOptions/M02_SO_Text.ts | 104 ++++-- resources/tester/helper_m02_so_def_model.cjs | 112 ++++++ resources/tester/test_m02_so_def_model.py | 87 +++++ 14 files changed, 1857 insertions(+), 344 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts create mode 100644 resources/tester/helper_m02_so_def_model.cjs create mode 100644 resources/tester/test_m02_so_def_model.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 5fc86b60a..12561432e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46) + // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) const extras = layer === "system" ? { - 옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null, + 옵션: section(options.title, options.body), 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index a8bafd1fa..a91a2578f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -1,56 +1,29 @@ /* ============================================================================= * M02_SO_Api.ts - * 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py` - * (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`) + * 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48) * ========================================================================== */ import { call } from "../M02_MasterTemplete_Api_Fetch"; +import type { StructDef } from "./M02_SO_Def_Model"; -export interface OptionKind { - type_id: string; - group: string; - name: string; - /** 거짓이면 B05 선택지에 안 나오는 종류 */ - enabled: boolean; - options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[]; +/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */ + +/** 지난 판 한 줄 */ +export interface DefVersion { + 판: number; + 저장일: string; } -/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */ -export interface OptionsDoc { - 판: string; - 표시: Record>; - 종류: OptionKind[]; -} +/** 정의 한 벌 — 읽기 = 로그인 누구나 */ +export const fetchStructureDef = (): Promise => call("/structure-definition"); -export const fetchStructureOptions = (): Promise => - call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자 +/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */ +export const saveStructureDef = (판: number, 정의: StructDef): Promise => + call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) }); -/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => - call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); +/** 지난 판 목록(`구조물정의_이력/판_N.json`) */ +export const fetchStructureDefHistory = (): Promise => + call("/structure-definition/history"); -/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */ - -/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */ -export interface OptionGroupDef { - id: string; - 이름: string; - 옵션: string[]; -} - -/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */ -export interface GroupsDoc { - 판: string; - 그룹: Record; - 종류: OptionKind[]; -} - -export const fetchStructureOptionGroups = (): Promise => - call("/structure-option-groups"); - -/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptionGroups = ( - 판: string, - 그룹: GroupsDoc["그룹"], -): Promise => - call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) }); +export const fetchStructureDefAt = (판: number): Promise => + call(`/structure-definition/history/${판}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts new file mode 100644 index 000000000..26899fdc1 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * M02_SO_Def.ts + * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) · + * 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) · + * 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄). + * 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1). + * ========================================================================== */ + +import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; +import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; +import { + fetchStructureDef, + fetchStructureDefAt, + fetchStructureDefHistory, + saveStructureDef, + type DefVersion, +} from "./M02_SO_Api"; +import { buildLayoutBlock } from "./M02_SO_Def_Layout"; +import { + baseShown, + clone, + problems, + stepFamily, + stepType, + type StructDef, +} from "./M02_SO_Def_Model"; +import { buildOptionsBlock } from "./M02_SO_Def_Options"; +import { buildRulesBlock } from "./M02_SO_Def_Rules"; +import { checkBox, textBox } from "./M02_SO_Def_Ui"; +import { buildGroupBlock } from "./M02_SO_Groups"; +import { to } from "./M02_SO_Text"; + +export interface DefScreenOptions { + /** 시스템 관리자 — 아니면 읽기만 */ + isAdmin: boolean; + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ +function tidy(def: StructDef): StructDef { + const out = clone(def); + for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record[]) + for (const [k, v] of Object.entries(part)) + if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k]; + return out; +} + +/** 작은 단추 */ +function smallButton( + label: string, + run: () => void, + off = false, + variant: "ghost" | "danger" = "ghost", +): HTMLButtonElement { + const b = createButton({ label, variant, size: "sm" }); + b.disabled = off; + b.addEventListener("click", run); + return b; +} + +/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */ +export async function openDefScreen(opt: DefScreenOptions): Promise { + let def: StructDef; + try { + def = await fetchStructureDef(); + } catch (error) { + showToast(to("Load_Failed", why(error)), "error"); + return false; + } + drawDef(def, opt); + return true; +} + +function drawDef(first: StructDef, opt: DefScreenOptions): void { + /** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */ + let base = first; + let work = clone(first); + let past: StructDef | null = null; + let dirty = false; + let current: string | null = null; + const open = new Set(); // 선택지를 펼친 옵션 + let versions: DefVersion[] | null = null; + + const shown = (): StructDef => past ?? work; + const canEdit = (): boolean => opt.isAdmin && !past; + const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } }); + const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } }); + const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } }); + const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } }); + const historyBox = el("div", { + className: "m02-sod__history", + attrs: { "data-sod-history": "" }, + }); + + const changed = (): void => { + dirty = true; + sync(); + paint(); + }; + + const paintKinds = (): void => { + const d = shown(); + const edit = canEdit(); + const parts: HTMLElement[] = []; + const families = Object.keys(d.구조물군); + families.forEach((g, gi) => { + parts.push( + el("div", { + className: "m02-sod__family", + attrs: { "data-sod-family": g }, + children: [ + textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, { + edit, + attr: "data-sod-family-name", + }), + ...(edit + ? [ + smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0), + smallButton( + to("GroupDown"), + () => stepFamily(d, g, 1) && changed(), + gi === families.length - 1, + ), + ] + : []), + ], + }), + ); + const kinds = d.types.filter((t) => t.group === g); + kinds.forEach((t, ti) => { + const off = t.enabled === false; + const groups = d.그룹[t.type_id]?.length ?? 0; + const b = el("button", { + className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`, + attrs: { type: "button", "data-sod-kind": t.type_id }, + children: [ + el("span", { text: t.name }), + ...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []), + ...(off ? [createTag(to("Off"), "neutral")] : []), + ], + }); + b.addEventListener("click", () => { + current = t.type_id; + paint(); + }); + parts.push( + el("div", { + className: "m02-sod__kindrow", + children: [ + b, + ...(edit + ? [ + smallButton( + to("GroupUp"), + () => stepType(d, t.type_id, -1) && changed(), + ti === 0, + ), + smallButton( + to("GroupDown"), + () => stepType(d, t.type_id, 1) && changed(), + ti === kinds.length - 1, + ), + ] + : []), + ], + }), + ); + }); + }); + kindList.replaceChildren(...parts); + }; + + const paintDetail = (): void => { + const d = shown(); + const type = d.types.find((t) => t.type_id === current); + if (!type) { + detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); + return; + } + const edit = canEdit(); + const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); + detail.replaceChildren( + el("div", { + className: "m02-sod__kindhead", + children: [ + textBox(type.name, (v) => (type.name = v), changed, { + edit, + attr: "data-sod-kind-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: type.type_id }), + checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), { + edit, + attr: "data-sod-kind-off", + }), + checkBox( + to("BaseStationShow"), + baseShown(d, type.type_id), + (v) => { + if (v) delete extra().기준측점; + else extra().기준측점 = false; + changed(); + }, + { edit, attr: "data-sod-base" }, + ), + ...(d.종류[type.type_id]?.동작 ?? []).map((a) => { + const tag = createTag(a, "neutral"); + tag.title = d.동작[a] ?? ""; + return tag; + }), + ], + }), + buildOptionsBlock({ def: d, type, edit, open, changed }), + buildLayoutBlock({ def: d, type, edit, changed }), + buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), + buildRulesBlock({ def: d, type, edit, changed }), + ); + }; + + const paintFoot = (): void => { + banner.replaceChildren( + ...(past + ? [ + el("span", { text: to("PastView", String(past.판)) }), + smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())), + ] + : [ + el("span", { text: to("Version", String(base.판)) }), + ...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]), + ]), + ); + banner.classList.toggle("is-past", !!past); + const list = past ? [] : problems(work); + issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t }))); + save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0; + }; + + const paint = (): void => { + paintKinds(); + paintDetail(); + paintFoot(); + }; + + const reload = async (): Promise => { + try { + base = await fetchStructureDef(); + work = clone(base); + dirty = false; + sync(); + } catch (again) { + showToast(to("Load_Failed", why(again)), "error"); + } + }; + + const save = createButton({ label: to("Save"), variant: "filled" }); + save.setAttribute("data-sod-save", ""); + save.addEventListener("click", async () => { + if (problems(work).length) return; + save.disabled = true; // 연달아 눌러도 한 번만 + try { + base = await saveStructureDef(base.판, tidy(work)); + work = clone(base); + dirty = false; + versions = null; + sync(); + showToast(to("SavedVersion", String(base.판)), "success"); + } catch (error) { + if (error instanceof StaleError) { + showToast(to("Stale"), "error"); + await reload(); + } else showToast(to("Failed", why(error)), "error"); + } finally { + paint(); + paintHistory(); + } + }); + + /** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */ + const paintHistory = (): void => { + if (!versions) return historyBox.replaceChildren(); + historyBox.replaceChildren( + ...(versions.length + ? versions.map((v) => { + const b = createButton({ + label: to("PastItem", `${v.판} · ${v.저장일}`), + variant: past?.판 === v.판 ? "filled" : "ghost", + size: "sm", + }); + b.setAttribute("data-sod-past", String(v.판)); + b.addEventListener("click", async () => { + try { + past = await fetchStructureDefAt(v.판); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + paint(); + paintHistory(); + }); + return b; + }) + : [el("p", { className: "m02-side__empty", text: to("PastNone") })]), + ); + }; + const history = createButton({ label: to("PastList"), variant: "ghost" }); + history.setAttribute("data-sod-history-toggle", ""); + history.addEventListener("click", async () => { + if (versions) { + versions = null; + return paintHistory(); + } + try { + versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + paintHistory(); + }); + + const node = el("div", { + className: "m02-opts m02-sod", + children: [ + el("p", { className: "m02-side__empty", text: to("DefNote") }), + banner, + el("div", { className: "m02-opts__panes", children: [kindList, detail] }), + issues, + el("div", { + className: "m02-side__row", + children: [...(opt.isAdmin ? [save] : []), history], + }), + historyBox, + ], + }); + const setDirty = opt.show(to("Screen"), node); + const sync = (): void => setDirty(dirty); + paint(); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts new file mode 100644 index 000000000..d6e4338e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * M02_SO_Def_Layout.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸) + * 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강). + * 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { + baseShown, + GRID_COLS, + live, + overlaps, + placeAll, + type Spot, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { head, numberBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +export interface LayoutBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + changed: () => void; +} + +const ROW_MAX = 60; + +/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */ +function preview(def: StructDef, type: TypeDef, cells: Map): HTMLElement { + const bad = new Set(overlaps(cells)); + const base = baseShown(def, type.type_id); + const offset = base ? 1 : 0; + const boxes: HTMLElement[] = []; + if (base) + boxes.push( + el("div", { + className: "m02-sod__cell is-base", + text: to("BaseStation"), + attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` }, + }), + ); + for (const o of live(type.options)) { + const c = cells.get(o.key)!; + boxes.push( + el("div", { + className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`, + text: o.label, + attrs: { + "data-sod-cell": o.key, + title: o.label, + style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`, + }, + }), + ); + } + return el("div", { + className: "m02-sod__grid", + attrs: { + "data-sod-preview": "", + style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`, + }, + children: boxes, + }); +} + +export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement { + const { def, type, edit, changed } = opt; + const cells = placeAll(type.options, def.배치[type.type_id]); + const rows = live(type.options).map((o) => { + const c = cells.get(o.key)!; + const fix = (patch: Partial): void => { + (def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch }; + changed(); + }; + return el("div", { + className: "m02-sod__place", + attrs: { "data-sod-place-row": o.key }, + children: [ + el("span", { className: "m02-sod__placename", text: o.label }), + el("small", { text: to("GridRow") }), + numberBox(c.줄, (n) => fix({ 줄: n }), { + edit, + min: 1, + max: ROW_MAX, + attr: "data-sod-row", + }), + el("small", { text: to("GridCol") }), + numberBox(c.칸, (n) => fix({ 칸: n }), { + edit, + min: 1, + max: GRID_COLS, + attr: "data-sod-col", + }), + el("small", { text: to("GridWidth") }), + numberBox(c.너비, (n) => fix({ 너비: n }), { + edit, + min: 1, + max: GRID_COLS, + attr: "data-sod-width", + }), + ], + }); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("LayoutHead"), to("LayoutNote")), + el("div", { + className: "m02-sod__layout", + children: [ + el("div", { className: "m02-sod__places", children: rows }), + preview(def, type, cells), + ], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts new file mode 100644 index 000000000..d45d9511b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -0,0 +1,268 @@ +/* ============================================================================= + * M02_SO_Def_Model.ts + * 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음. + * 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`). + * 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`). + * 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈. + * ========================================================================== */ + +export type OptionForm = "select" | "number" | "text" | "bool"; + +export interface Spot { + 줄: number; + 칸: number; + 너비: number; +} + +export interface OptionDef { + key: string; + label: string; + input: OptionForm; + choices?: string[]; + unit?: string | null; + default?: unknown; + enabled?: boolean; + phase?: "b05" | "detail"; + [more: string]: unknown; +} + +export interface TypeDef { + type_id: string; + group: string; + name: string; + options: OptionDef[]; + enabled?: boolean; + [more: string]: unknown; +} + +export type ShapeKey = "길이" | "높이" | "전" | "후"; +export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"]; + +export interface GroupDef { + id: string; + 이름: string; + 옵션: string[]; + 형상?: Partial>; +} + +/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */ +export interface RuleDef { + 종류: string; + 만약: { 옵션: string; 값들: string[] }; + 그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown }; +} + +export interface StructDef { + 판: number; + 구조물군: Record; + 동작: Record; + types: TypeDef[]; + 종류: Record; + 표시: Record>; + 그룹: Record; + 배치: Record>; + 규칙: RuleDef[]; + [more: string]: unknown; +} + +export const NAME_MAX = 40; +/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */ +export const GRID_COLS = 2; + +/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */ +export const clone = (value: T): T => structuredClone(value); + +/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */ +export function newKey(prefix: string, taken: Iterable): string { + const used = new Set(taken); + for (;;) { + const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`; + if (key.length > prefix.length + 3 && !used.has(key)) return key; + } +} + +/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */ +export function step(list: T[], index: number, delta: number): boolean { + const at = index + delta; + if (index < 0 || at < 0 || at >= list.length) return false; + [list[index], list[at]] = [list[at], list[index]]; + return true; +} + +/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */ +export const live = (list: readonly T[]): T[] => + list.filter((x) => x.enabled !== false); + +/** 기준 측점 칸 보이기 — 없으면 보임 */ +export const baseShown = (def: StructDef, typeId: string): boolean => + def.종류[typeId]?.기준측점 !== false; + +/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */ +export function badNames(names: readonly string[]): string[] { + const seen = new Set(); + const bad: string[] = []; + for (const raw of names) { + const name = raw.trim(); + if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)"); + seen.add(name); + } + return bad; +} + +/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */ +export function stepFamily(def: StructDef, key: string, delta: number): boolean { + const entries = Object.entries(def.구조물군); + if ( + !step( + entries, + entries.findIndex(([k]) => k === key), + delta, + ) + ) + return false; + def.구조물군 = Object.fromEntries(entries); + return true; +} + +/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */ +export function stepType(def: StructDef, typeId: string, delta: number): boolean { + const t = def.types.find((x) => x.type_id === typeId); + if (!t) return false; + const same = def.types.filter((x) => x.group === t.group); + const other = same[same.indexOf(t) + delta]; + if (!other) return false; + const i = def.types.indexOf(t); + const j = def.types.indexOf(other); + [def.types[i], def.types[j]] = [def.types[j], def.types[i]]; + return true; +} + +/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */ +export function renameChoice( + def: StructDef, + typeId: string, + o: OptionDef, + from: string, + to: string, +): void { + if (from === to || !o.choices) return; + o.choices = o.choices.map((c) => (c === from ? to : c)); + if (o.default === from) o.default = to; + for (const r of def.규칙) { + if (r.종류 !== typeId) continue; + if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v)); + if (r.그러면.옵션 === o.key) { + if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v)); + if (r.그러면.기본값 === from) r.그러면.기본값 = to; + } + } +} + +/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */ +export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void { + o.choices = (o.choices ?? []).filter((c) => c !== value); + if (o.default === value) o.default = null; + for (const r of def.규칙) { + if (r.종류 !== typeId) continue; + if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value); + if (r.그러면.옵션 === o.key) { + if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value); + if (r.그러면.기본값 === value) delete r.그러면.기본값; + } + } +} + +/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */ +export function placeAll( + options: readonly OptionDef[], + spots: Record = {}, + cols = GRID_COLS, +): Map { + const out = new Map(); + const taken = new Set(); + const mark = (c: Spot): void => { + for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`); + }; + const shown = live(options); + for (const o of shown) { + const s = spots[o.key]; + if (!s) continue; + const width = Math.max(1, Math.min(cols, s.너비 || 1)); + const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width }; + out.set(o.key, cell); + mark(cell); + } + let row = 1; + let col = 1; + for (const o of shown) { + if (out.has(o.key)) continue; + while (taken.has(`${row}:${col}`)) { + col += 1; + if (col > cols) ((row += 1), (col = 1)); + } + const cell = { 줄: row, 칸: col, 너비: 1 }; + out.set(o.key, cell); + mark(cell); + } + return out; +} + +/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */ +export function overlaps(cells: Map): string[] { + const owner = new Map(); + const bad = new Set(); + for (const [key, c] of cells) + for (let i = 0; i < c.너비; i += 1) { + const at = `${c.줄}:${c.칸 + i}`; + const other = owner.get(at); + if (other) bad.add(other).add(key); + else owner.set(at, key); + } + return [...bad]; +} + +/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */ +export function ruleBad(def: StructDef, r: RuleDef): boolean { + const t = def.types.find((x) => x.type_id === r.종류); + const src = t?.options.find((o) => o.key === r.만약.옵션); + const dst = t?.options.find((o) => o.key === r.그러면.옵션); + if (!src || !dst || src === dst || !r.만약.값들.length) return true; + if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true; + const { 선택지, 보이기, 기본값 } = r.그러면; + if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true; + let allowed = dst.choices ?? []; + if (선택지 !== undefined) { + if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true; + allowed = 선택지; + } + return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값)); +} + +/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */ +export function problems(def: StructDef): string[] { + const out: string[] = []; + const fam = badNames(Object.values(def.구조물군)); + if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`); + for (const g of Object.keys(def.구조물군)) { + const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name)); + if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`); + } + for (const t of def.types) { + const opt = badNames(t.options.map((o) => o.label)); + if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`); + for (const o of t.options) { + if (o.input !== "select") continue; + const ch = badNames(o.choices ?? []); + if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`); + if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); + } + const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); + if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); + const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); + if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); + } + def.규칙.forEach((r, i) => { + if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`); + }); + return out; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts new file mode 100644 index 000000000..10254bba8 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * M02_SO_Def_Options.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록** + * (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) + + * 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]). + * 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) · + * 저장은 화면 [저장](`M02_SO_Def.ts`). + * ========================================================================== */ + +import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { + dropChoice, + newKey, + renameChoice, + type OptionDef, + type OptionForm, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { buildPlaceSelect } from "./M02_SO_Groups"; +import { to } from "./M02_SO_Text"; + +export interface OptionsBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + /** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */ + open: Set; + changed: () => void; +} + +const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"]; +const FORM_TEXT = { + select: "FormSelect", + number: "FormNumber", + text: "FormText", + bool: "FormBool", +} as const; + +/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */ +function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement { + const attr = "data-sod-default"; + const now = o.default == null ? "" : String(o.default); + if (o.input === "select" || o.input === "bool") { + const pairs: [string, string][] = + o.input === "select" + ? (o.choices ?? []).map((c) => [c, c]) + : [ + ["true", to("Yes")], + ["false", to("No")], + ]; + return pickBox( + [["", to("NoDefault")], ...pairs], + now, + (v) => { + o.default = v === "" ? null : o.input === "bool" ? v === "true" : v; + changed(); + }, + { edit, attr, cls: "m02-sod__default" }, + ); + } + return textBox( + now, + (v) => { + const n = Number(v); + o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v; + }, + changed, + { edit, attr, cls: "m02-sod__default", place: to("NoDefault") }, + ); +} + +/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */ +function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement { + const { def, type, edit, changed } = opt; + const list = (o.choices ??= []); + const rows = list.map((c) => { + let next = c; + const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" }); + del.addEventListener("click", async () => { + if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return; + dropChoice(def, type.type_id, o, c); + changed(); + }); + return el("div", { + className: "m02-sod__choice", + attrs: { "data-sod-choice": c }, + children: [ + textBox( + c, + (v) => (next = v.trim()), + () => { + if (next && next !== c && !list.includes(next)) + renameChoice(def, type.type_id, o, c, next); + changed(); + }, + { edit, attr: "data-sod-choice-name" }, + ), + ...moveButtons(list, c, changed, edit), + ...(edit ? [del] : []), + ], + }); + }); + const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-choice-add", ""); + add.addEventListener("click", () => { + let n = list.length + 1; + while (list.includes(to("ChoiceNew", String(n)))) n += 1; + list.push(to("ChoiceNew", String(n))); + changed(); + }); + return el("div", { + className: "m02-sod__choices", + children: [ + el("p", { className: "m02-side__empty", text: to("ChoiceNote") }), + ...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]), + ...(edit ? [add] : []), + ], + }); +} + +/** 옵션 한 줄(+ 펼친 선택지) */ +function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement { + const { def, type, edit, open, changed } = opt; + const off = o.enabled === false; + const hidden = def.표시[type.type_id]?.[o.key] === false; + const form = pickBox( + FORMS.map((f) => [f, to(FORM_TEXT[f])] as const), + o.input, + (v) => { + o.input = v as OptionForm; + o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음 + if (o.input === "select") open.add(o.key); + changed(); + }, + { edit, attr: "data-sod-form", cls: "m02-sod__form" }, + ); + const choices = + o.input === "select" + ? createButton({ + label: to("Choices", String(o.choices?.length ?? 0)), + variant: open.has(o.key) ? "filled" : "ghost", + size: "sm", + }) + : null; + choices?.setAttribute("data-sod-choices", o.key); + choices?.addEventListener("click", () => { + if (open.has(o.key)) open.delete(o.key); + else open.add(o.key); + changed(); + }); + const show = checkBox( + to("Shown"), + !hidden, + (v) => { + const row = (def.표시[type.type_id] ??= {}); + if (v) delete row[o.key]; + else row[o.key] = false; + if (!Object.keys(row).length) delete def.표시[type.type_id]; + changed(); + }, + { edit, attr: "data-sod-shown" }, + ); + const row = el("div", { + className: `m02-sod__option${off ? " is-off" : ""}`, + attrs: { "data-sod-option": o.key }, + children: [ + textBox(o.label, (v) => (o.label = v), changed, { + edit, + attr: "data-sod-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: o.key }), + ...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []), + form, + textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, { + edit, + attr: "data-sod-unit", + cls: "m02-sod__unit", + place: to("Unit"), + max: 12, + }), + defaultBox(o, edit, changed), + show, + buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed), + checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), { + edit, + attr: "data-sod-off", + }), + ...moveButtons(type.options, o, changed, edit), + ...(choices ? [choices] : []), + ], + }); + return o.input === "select" && open.has(o.key) + ? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] }) + : row; +} + +/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */ +export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement { + const { type, edit, changed } = opt; + const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-option-add", ""); + add.addEventListener("click", () => { + const n = type.options.length + 1; + const o: OptionDef = { + key: newKey( + "opt_", + type.options.map((x) => x.key), + ), + label: to("OptionNew", String(n)), + input: "select", + choices: [to("ChoiceNew", "1")], + default: null, + }; + type.options.push(o); + opt.open.add(o.key); + changed(); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("OptionHead"), to("OptionNote")), + ...(type.options.length + ? type.options.map((o) => optionRow(opt, o)) + : [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]), + ...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts new file mode 100644 index 000000000..bca6186e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * M02_SO_Def_Rules.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만) + * 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기. + * 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { + live, + ruleBad, + type OptionDef, + type RuleDef, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { head, pickBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +export interface RulesBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + changed: () => void; +} + +const bool = (): [string, string][] => [ + ["true", to("Yes")], + ["false", to("No")], +]; + +/** 여럿 고르기 — 체크한 것만 차례대로 */ +function multiPick( + all: readonly string[], + picked: readonly string[], + set: (v: string[]) => void, + edit: boolean, + attr: string, +): HTMLElement { + const on = new Set(picked); + return el("div", { + className: "m02-sod__rulechoices", + attrs: { [attr]: "" }, + children: all.map((c) => { + const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement; + box.checked = on.has(c); + box.disabled = !edit; + box.addEventListener("change", () => { + if (box.checked) on.add(c); + else on.delete(c); + set(all.filter((x) => on.has(x))); + }); + return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] }); + }), + }); +} + +/** 글 칸 — 쉼표로 여럿 */ +function listText( + value: string[], + set: (v: string[]) => void, + edit: boolean, + attr: string, +): HTMLElement { + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__rulepick", + attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") }, + }) as HTMLInputElement; + box.value = value.join(", "); + box.disabled = !edit; + box.addEventListener("change", () => + set( + box.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ), + ); + return box; +} + +/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */ +function whenValues( + src: OptionDef | undefined, + rule: RuleDef, + edit: boolean, + changed: () => void, +): HTMLElement { + const set = (v: string[]): void => ((rule.만약.값들 = v), changed()); + if (src?.input === "select") + return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value"); + if (src?.input === "bool") + return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), { + edit, + attr: "data-sod-rule-value", + cls: "m02-sod__rulepick", + }); + return listText(rule.만약.값들, set, edit, "data-sod-rule-value"); +} + +/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */ +function thenDefault( + dst: OptionDef | undefined, + rule: RuleDef, + edit: boolean, + changed: () => void, +): HTMLElement { + const then = rule.그러면; + const now = then.기본값 === undefined ? "" : String(then.기본값); + const set = (v: string): void => { + if (v === "") delete then.기본값; + else + then.기본값 = + dst?.input === "bool" + ? v === "true" + : dst?.input === "number" && Number.isFinite(Number(v)) + ? Number(v) + : v; + changed(); + }; + const pairs: [string, string][] = + dst?.input === "select" + ? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c]) + : dst?.input === "bool" + ? bool() + : []; + if (pairs.length || (dst?.input !== "number" && dst?.input !== "text")) + return pickBox([["", to("KeepDefault")], ...pairs], now, set, { + edit, + attr: "data-sod-rule-default", + cls: "m02-sod__rulepick", + }); + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__rulepick", + attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") }, + }) as HTMLInputElement; + box.value = now; + box.disabled = !edit; + box.addEventListener("change", () => set(box.value.trim())); + return box; +} + +function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement { + const { def, type, edit, changed } = opt; + const byKey = new Map(type.options.map((o) => [o.key, o])); + const names = live(type.options).map((o) => [o.key, o.label] as [string, string]); + const src = byKey.get(rule.만약.옵션); + const dst = byKey.get(rule.그러면.옵션); + const then = rule.그러면; + const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" }); + del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed())); + const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0"; + return el("div", { + className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`, + attrs: { "data-sod-rule": String(index) }, + children: [ + el("small", { text: to("RuleIf") }), + pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), { + edit, + attr: "data-sod-rule-top", + cls: "m02-sod__rulepick", + }), + el("small", { text: "=" }), + whenValues(src, rule, edit, changed), + el("small", { text: to("RuleThen") }), + pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), { + edit, + attr: "data-sod-rule-target", + cls: "m02-sod__rulepick", + }), + ...(dst?.input === "select" + ? [ + el("small", { text: to("RuleChoices") }), + multiPick( + dst.choices ?? [], + then.선택지 ?? [], + (v) => { + if (v.length) then.선택지 = v; + else delete then.선택지; + changed(); + }, + edit, + "data-sod-rule-choice", + ), + ] + : []), + pickBox( + [ + ["", to("Keep")], + ["1", to("Show")], + ["0", to("Hide")], + ], + show, + (v) => { + if (v === "") delete then.보이기; + else then.보이기 = v === "1"; + changed(); + }, + { edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" }, + ), + thenDefault(dst, rule, edit, changed), + ...(edit ? [del] : []), + ], + }); +} + +export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement { + const { def, type, edit, changed } = opt; + const mine = def.규칙.filter((r) => r.종류 === type.type_id); + const opts = live(type.options); + const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-rule-add", ""); + add.disabled = opts.length < 2; + add.addEventListener("click", () => { + const top = opts.find((o) => o.input === "select") ?? opts[0]; + const target = opts.find((o) => o.key !== top.key)!; + def.규칙.push({ + 종류: type.type_id, + 만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] }, + 그러면: { 옵션: target.key, 보이기: false }, + }); + changed(); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("RuleHead"), to("RuleNote")), + ...(mine.length + ? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r))) + : [el("p", { className: "m02-side__empty", text: to("RuleNone") })]), + ...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts new file mode 100644 index 000000000..837c344bd --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * M02_SO_Def_Ui.ts + * 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추. + * 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { step } from "./M02_SO_Def_Model"; +import { to } from "./M02_SO_Text"; + +/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */ +export function textBox( + value: string, + set: (v: string) => void, + done: () => void, + opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number }, +): HTMLInputElement { + const box = el("input", { + className: `ui-input ui-input--sm ${opt.cls ?? ""}`, + attrs: { + type: "text", + maxlength: String(opt.max ?? 40), + ...(opt.attr ? { [opt.attr]: "" } : {}), + }, + }) as HTMLInputElement; + box.value = value; + box.placeholder = opt.place ?? ""; + box.disabled = !opt.edit; + box.addEventListener("input", () => { + set(box.value); + box.classList.toggle("is-invalid", !box.value.trim() && !opt.place); + }); + box.addEventListener("change", done); + return box; +} + +/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */ +export function numberBox( + value: number, + set: (v: number) => void, + opt: { edit: boolean; min: number; max: number; attr?: string }, +): HTMLInputElement { + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__num", + attrs: { + type: "number", + min: String(opt.min), + max: String(opt.max), + ...(opt.attr ? { [opt.attr]: "" } : {}), + }, + }) as HTMLInputElement; + box.value = String(value); + box.disabled = !opt.edit; + box.addEventListener("change", () => { + const n = Math.round(Number(box.value)); + if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) { + box.value = String(value); + return; + } + set(n); + }); + return box; +} + +/** 체크 + 글 */ +export function checkBox( + label: string, + on: boolean, + set: (v: boolean) => void, + opt: { edit: boolean; attr?: string }, +): HTMLLabelElement { + const box = el("input", { + attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) }, + }) as HTMLInputElement; + box.checked = on; + box.disabled = !opt.edit; + box.addEventListener("change", () => set(box.checked)); + return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] }); +} + +/** 고르기 — [값, 글] 짝 */ +export function pickBox( + pairs: readonly (readonly [string, string])[], + value: string, + set: (v: string) => void, + opt: { edit: boolean; attr?: string; cls?: string }, +): HTMLSelectElement { + const box = el("select", { + className: `ui-input ui-input--sm ${opt.cls ?? ""}`, + attrs: opt.attr ? { [opt.attr]: "" } : {}, + }) as HTMLSelectElement; + box.append(...pairs.map(([v, t]) => new Option(t, v))); + box.value = value; + box.disabled = !opt.edit; + box.addEventListener("change", () => set(box.value)); + return box; +} + +/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */ +export function moveButtons( + list: T[], + item: T, + changed: () => void, + edit: boolean, +): HTMLElement[] { + if (!edit) return []; + const index = list.indexOf(item); + const make = (label: string, delta: number, off: boolean): HTMLButtonElement => { + const b = createButton({ label, variant: "ghost", size: "sm" }); + b.disabled = off; + b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed()); + return b; + }; + return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)]; +} + +/** 절 제목 + 안내 한 줄 */ +export const head = (title: string, note?: string): HTMLElement[] => [ + el("h4", { className: "m02-opts__group", text: title }), + ...(note ? [el("p", { className: "m02-side__empty", text: note })] : []), +]; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts index 242dad8b4..410875cf4 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts @@ -1,19 +1,21 @@ /* ============================================================================= * M02_SO_Groups.ts - * 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록 - * (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹). - * 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`). + * 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록 + * (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) · + * 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹). + * 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`). * 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`). * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import type { OptionGroupDef } from "./M02_SO_Api"; +import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model"; +import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { to } from "./M02_SO_Text"; const NAME_MAX = 40; /** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */ -export function newGroupId(list: readonly OptionGroupDef[]): string { +export function newGroupId(list: readonly GroupDef[]): string { const taken = new Set(list.map((g) => g.id)); for (;;) { const id = `g${Math.random().toString(36).slice(2, 8)}`; @@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string { } /** 그 옵션이 든 그룹 id — 없으면 null(본체) */ -export function groupOf(list: readonly OptionGroupDef[], key: string): string | null { +export function groupOf(list: readonly GroupDef[], key: string): string | null { return list.find((g) => g.옵션.includes(key))?.id ?? null; } /** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */ -export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void { +export function moveOption(list: GroupDef[], key: string, gid: string | null): void { for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key); const target = gid ? list.find((g) => g.id === gid) : null; if (target) target.옵션.push(key); } -/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */ -export function namesOk(groups: Map): boolean { - for (const list of groups.values()) { - const names = list.map((g) => g.이름.trim()); - if (names.some((n) => !n || n.length > NAME_MAX)) return false; - if (new Set(names).size !== names.length) return false; - } - return true; -} - export interface GroupBlockOptions { /** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */ - list: OptionGroupDef[]; + list: GroupDef[]; edit: boolean; /** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */ changed: () => void; } +/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */ +function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement { + const shape = (group.형상 ??= {}); + return el("div", { + className: "m02-sog__shape", + attrs: { "data-sog-shape": group.id }, + children: SHAPE_KEYS.map((k) => { + const f = shape[k] ?? { 받기: false, 뜻: "" }; + const on = checkBox( + to(`Shape_${k}` as const), + f.받기, + (v) => ((shape[k] = { ...f, 받기: v }), changed()), + { edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` }, + ); + const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, { + edit: edit && f.받기, + cls: "m02-sog__mean", + place: to("ShapeMean"), + }); + return el("div", { className: "m02-sog__shapecell", children: [on, mean] }); + }), + }); +} + /** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { const { list } = opt; @@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { list.splice(list.indexOf(group), 1); opt.changed(); }); - return el("div", { + const line = el("div", { className: "m02-sog__row", attrs: { "data-sog-group": group.id }, children: [ @@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { ...(opt.edit ? [up, down, del] : []), ], }); + return el("div", { + className: "m02-sog__item", + children: [line, shapeLine(group, opt.edit, opt.changed)], + }); }); const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" }); add.setAttribute("data-sog-add", ""); @@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { /** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */ export function buildPlaceSelect( - list: OptionGroupDef[], + list: GroupDef[], key: string, edit: boolean, changed: () => void, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index d340d13f6..7ede2af54 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -1,25 +1,12 @@ /* ============================================================================= * M02_SO_Options.ts - * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에 - * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 - * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. - * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). - * 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`). + * 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에 + * 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만. + * 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감. * ========================================================================== */ -import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; -import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; -import { - fetchStructureOptionGroups, - fetchStructureOptions, - saveStructureOptionGroups, - saveStructureOptions, - type GroupsDoc, - type OptionGroupDef, - type OptionKind, - type OptionsDoc, -} from "./M02_SO_Api"; -import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups"; +import { el } from "@ui/ui_template_elements"; +import { openDefScreen } from "./M02_SO_Def"; import "./M02_SO_Style.css"; import { to } from "./M02_SO_Text"; @@ -40,8 +27,6 @@ export interface OptionsSection { clear: () => void; } -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); - export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection { const row = el("button", { className: "m02-side__item ui-sidebar-row", @@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection attrs: { type: "button", "data-options-row": "" }, }); const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); - row.addEventListener("click", () => void open()); /** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */ let opened = false; @@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection opened = on; row.classList.toggle("is-active", on); }; - - async function open(): Promise { + row.addEventListener("click", async () => { if (opened || !(await opt.confirmLeave())) return; - let doc: OptionsDoc; - let groups: GroupsDoc; - try { - [doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]); - } catch (error) { - return showToast(to("Load_Failed", why(error)), "error"); - } - draw(doc, groups); - } - - /** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ - function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void { - let version = doc.판; - let groupVersion = groupsDoc.판; - const edit = opt.isAdmin; - /** 숨긴 옵션 — 종류 → 키 집합 */ - const hidden = new Map>(); - const load = (src: OptionsDoc["표시"]): void => { - hidden.clear(); - for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys))); - }; - /** 그룹 — 종류 → 그룹[](고칠 사본) */ - const groups = new Map(); - const loadGroups = (src: GroupsDoc["그룹"]): void => { - groups.clear(); - for (const [id, list] of Object.entries(src)) - groups.set( - id, - list.map((g) => ({ ...g, 옵션: [...g.옵션] })), - ); - }; - load(doc.표시); - loadGroups(groupsDoc.그룹); - /** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */ - let kinds = groupsDoc.종류; - let current: OptionKind | null = null; - let shownDirty = false; - let groupDirty = false; - - const kindList = el("div", { className: "m02-opts__kinds" }); - const optionList = el("div", { className: "m02-opts__list" }); - const off = (id: string): number => hidden.get(id)?.size ?? 0; - const groupList = (id: string): OptionGroupDef[] => { - if (!groups.has(id)) groups.set(id, []); - return groups.get(id)!; - }; - - const paintKinds = (): void => { - const parts: HTMLElement[] = []; - let group = ""; - for (const k of kinds) { - if (k.group !== group) { - group = k.group; - parts.push( - el("h4", { - className: "m02-opts__group", - text: group.length === 1 ? to("Group", group) : group, - }), - ); - } - const n = off(k.type_id); - const g = groups.get(k.type_id)?.length ?? 0; - const b = el("button", { - className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`, - attrs: { type: "button", "data-options-kind": k.type_id }, - children: [ - el("span", { text: k.name }), - ...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []), - ...(n ? [createTag(to("Hidden", String(n)), "warning")] : []), - ...(k.enabled ? [] : [createTag(to("Off"), "neutral")]), - ], - }); - b.addEventListener("click", () => { - current = k; - paintKinds(); - paintOptions(); - }); - parts.push(b); - } - kindList.replaceChildren(...parts); - }; - - /** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */ - const groupsChanged = (): void => { - groupDirty = true; - sync(); - paintKinds(); - paintOptions(); - }; - - const paintOptions = (): void => { - if (!current) { - optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); - return; - } - const kind = current; - const list = groupList(kind.type_id); - const rows = kind.options.map((o) => { - const box = el("input", { - attrs: { type: "checkbox", "data-options-key": o.key }, - }) as HTMLInputElement; - box.checked = !hidden.get(kind.type_id)?.has(o.key); - box.disabled = !edit; - box.addEventListener("change", () => { - const set = hidden.get(kind.type_id) ?? new Set(); - if (box.checked) set.delete(o.key); - else set.add(o.key); - hidden.set(kind.type_id, set); - shownDirty = true; - sync(); - paintKinds(); - }); - const tags: HTMLElement[] = []; - if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent")); - if (o.required && o.phase === "b05") { - const tag = createTag(to("Required"), "danger"); - tag.title = to("RequiredTip"); - tags.push(tag); - } - return el("div", { - className: "m02-opts__option", - children: [ - el("label", { - className: "m02-opts__check", - children: [ - box, - el("span", { text: o.label }), - el("small", { className: "m02-opts__key", text: o.key }), - ...tags, - ], - }), - buildPlaceSelect(list, o.key, edit, groupsChanged), - ], - }); - }); - optionList.replaceChildren( - el("h3", { className: "m02-options__head", text: kind.name }), - buildGroupBlock({ list, edit, changed: groupsChanged }), - el("h4", { className: "m02-opts__group", text: to("OptionHead") }), - ...(rows.length - ? rows - : [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]), - ); - }; - - const save = createButton({ label: to("Save"), variant: "filled" }); - const all = createButton({ label: to("ShowAll"), variant: "ghost" }); - all.addEventListener("click", () => { - if (![...hidden.values()].some((s) => s.size)) return; - hidden.clear(); - shownDirty = true; - sync(); - paintKinds(); - paintOptions(); - }); - - /** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */ - const reload = async (): Promise => { - try { - const [fresh, freshGroups] = await Promise.all([ - fetchStructureOptions(), - fetchStructureOptionGroups(), - ]); - version = fresh.판; - groupVersion = freshGroups.판; - kinds = freshGroups.종류; - load(fresh.표시); - loadGroups(freshGroups.그룹); - shownDirty = groupDirty = false; - sync(); - current = kinds.find((k) => k.type_id === current?.type_id) ?? null; - paintKinds(); - paintOptions(); - } catch (again) { - showToast(to("Load_Failed", why(again)), "error"); - } - }; - - save.addEventListener("click", async () => { - if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error"); - save.disabled = true; // 연달아 눌러도 한 번만 - try { - if (shownDirty) { - const 표시: OptionsDoc["표시"] = {}; - for (const [id, keys] of hidden) - if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); - const done = await saveStructureOptions(version, 표시); - version = done.판; - load(done.표시); - shownDirty = false; - } - if (groupDirty) { - const 그룹: GroupsDoc["그룹"] = {}; - for (const [id, list] of groups) - if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() })); - const done = await saveStructureOptionGroups(groupVersion, 그룹); - groupVersion = done.판; - loadGroups(done.그룹); - groupDirty = false; - } - sync(); - showToast(to("Saved"), "success"); - paintKinds(); - paintOptions(); - } catch (error) { - if (error instanceof StaleError) { - showToast(to("Stale"), "error"); - await reload(); - } else showToast(to("Failed", why(error)), "error"); - } finally { - save.disabled = false; - } - }); - - const node = el("div", { - className: "m02-opts", - children: [ - el("p", { className: "m02-side__empty", text: to("Note") }), - el("div", { - className: "m02-opts__panes", - children: [kindList, optionList], - }), - ...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []), - ], - }); - const setDirty = opt.show(to("Screen"), node); - const sync = (): void => setDirty(shownDirty || groupDirty); - paintKinds(); - paintOptions(); + if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return; opt.onOpen(); mark(true); - } + }); return { title: to("Title"), body, clear: () => mark(false) }; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 2670d6cfe..80d3e4cec 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -112,3 +112,200 @@ color: var(--color-text-muted); font-size: 11px; } + +/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */ +.m02-sod { + max-width: 80rem; +} + +.m02-sod__banner { + display: flex; + align-items: center; + gap: var(--spacing-8); + color: var(--color-text-muted); +} + +.m02-sod__banner.is-past { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + background: var(--color-mist-violet); + color: var(--color-text); +} + +.m02-sod__family, +.m02-sod__kindrow, +.m02-sod__kindhead, +.m02-sod__option, +.m02-sod__choice, +.m02-sod__place, +.m02-sod__rule { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sod__family { + margin-top: var(--spacing-8); +} + +.m02-sod__family > .ui-input { + flex: 1 1 8rem; + min-width: 0; + font-weight: 600; +} + +.m02-sod__kindrow > .m02-opts__kind { + flex: 1 1 auto; +} + +.m02-sod__block { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-sod__option { + padding: var(--spacing-4) 0; + border-bottom: 1px solid var(--color-border); +} + +.is-off > .ui-input, +.m02-opts__kind.is-off > span:first-child { + color: var(--color-text-muted); + text-decoration: line-through; +} + +.m02-sod__name { + flex: 1 1 10rem; + min-width: 0; +} + +.m02-sod__form, +.m02-sod__default { + width: 8rem; +} + +.m02-sod__unit, +.m02-sod__num { + width: 4.5rem; +} + +.m02-sod__check { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + white-space: nowrap; + cursor: pointer; +} + +.m02-sod__choices { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0 0 var(--spacing-8) var(--spacing-24); + padding-left: var(--spacing-8); + border-left: 2px solid var(--color-border); +} + +/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */ +.m02-sod__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem); + gap: var(--spacing-16); + align-items: start; +} + +.m02-sod__placename { + flex: 1 1 8rem; + min-width: 0; +} + +.m02-sod__grid { + display: grid; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-sod__cell { + overflow: hidden; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.m02-sod__cell.is-base { + background: var(--color-mist-violet); +} + +.m02-sod__cell.is-bad, +.m02-sod__rule.is-bad { + border-color: var(--color-danger); +} + +.m02-sod__rule { + padding: var(--spacing-4); + border: 1px solid transparent; + border-radius: var(--radius-sm); +} + +.m02-sod__rulepick { + width: 9rem; +} + +.m02-sod__rulechoices { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */ +.m02-sog__item { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.m02-sog__shape { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--spacing-4) var(--spacing-12); +} + +.m02-sog__shapecell { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.m02-sog__mean { + flex: 1 1 auto; + min-width: 0; +} + +.m02-sod__issues:empty, +.m02-sod__history:empty { + display: none; +} + +.m02-sod__history { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.m02-sod__bad { + margin: 0; + color: var(--color-danger); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 9b4409b41..90037edd8 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -6,13 +6,12 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { - Title: ["구조물 옵션 보이기", "Structure options shown"], - Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"], - Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"], - Note: [ - "체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임", - "Only checked options appear in the B05 · B06 forms — definitions and stored values stay", + Title: ["구조물 옵션 정의", "Structure option definition"], + Row: [ + "구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙", + "Families · types · options · layout · groups · rules", ], + Screen: ["구조물 옵션 정의", "Structure option definition"], GroupHead: ["그룹", "Groups"], GroupNote: [ "그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체", @@ -35,35 +34,92 @@ const TEXT = { ], GroupTag: ["그룹 {value}", "{value} groups"], Main: ["본체", "Main body"], - OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"], + OptionHead: ["옵션", "Options"], + OptionNote: [ + "이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침", + "Name · form · unit · default · container · unused · order — keys never change", + ], + OptionAdd: ["옵션 더함", "Add option"], + OptionNew: ["새 옵션 {value}", "New option {value}"], + FormSelect: ["고르기", "Select"], + FormNumber: ["숫자", "Number"], + FormText: ["글", "Text"], + FormBool: ["예/아니오", "Yes/No"], + Yes: ["예", "Yes"], + No: ["아니오", "No"], + Unit: ["단위", "Unit"], + NoDefault: ["기본값 없음", "No default"], + Unused: ["안 씀", "Unused"], + Choices: ["선택지 {value}", "{value} choices"], + ChoiceAdd: ["선택지 더함", "Add choice"], + ChoiceNew: ["새 선택지 {value}", "New choice {value}"], + ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"], + ChoiceNote: [ + "선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음", + "Choice text is the stored value — renaming updates defaults and rules here", + ], + ChoiceDrop: ["빼기", "Remove"], + ChoiceDropAsk: [ + "선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음", + "Remove choice “{value}” — also from defaults and rules", + ], + Shown: ["보이기", "Shown"], + RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"], + RuleChoices: ["선택지", "Choices"], + LayoutHead: ["배치 격자", "Layout grid"], + LayoutNote: [ + "B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로", + "Left panel layout (two columns) — row · column · width per option · overlaps in red", + ], + GridRow: ["줄", "Row"], + GridCol: ["칸", "Col"], + GridWidth: ["너비", "Width"], + BaseStation: ["기준 측점", "Base station"], + BaseStationShow: ["기준 측점 표시", "Show base station"], + Shape_길이: ["길이", "Length"], + Shape_높이: ["높이", "Height"], + Shape_전: ["기준 측점 전", "Before"], + Shape_후: ["기준 측점 후", "After"], + ShapeMean: ["무엇을 정하는지", "What it controls"], + RuleHead: ["규칙", "Rules"], + RuleNote: [ + "만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강", + "If parent = value → child choices · visibility · default", + ], + RuleNone: ["규칙 없음", "No rules"], + RuleAdd: ["규칙 더함", "Add rule"], + RuleIf: ["만약", "If"], + RuleThen: ["→", "→"], + Keep: ["보이기 그대로", "Keep visibility"], + Show: ["보임", "Show"], + Hide: ["숨김", "Hide"], + KeepDefault: ["기본값 그대로", "Keep default"], + DefNote: [ + "구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음", + "Structure definition — Save bumps the version; past versions stay in history", + ], + Version: ["판 {value}", "Version {value}"], + ReadOnly: ["읽기만", "Read only"], + SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"], + PastList: ["지난 판", "Past versions"], + PastItem: ["판 {value}", "v{value}"], + PastNone: ["지난 판 없음", "No past versions"], + PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"], + PastBack: ["지금 판으로", "Back to current"], NoOptionKind: [ "이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)", "No options — groups only (each links a combo)", ], - NameBad: [ - "그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨", - "Group names: 1–40 chars, unique", - ], - Group: ["{value}군", "Group {value}"], - Hidden: ["숨김 {value}", "{value} hidden"], Off: ["안 쓰는 종류", "Not offered"], - NoOption: ["옵션 없음", "No options"], Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"], Detail: ["B06 전용", "B06 only"], - Required: ["필수", "Required"], - RequiredTip: [ - "필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)", - "Required — when hidden it is not shown and not enforced", - ], Save: ["저장", "Save"], - ShowAll: ["모두 보임", "Show all"], - Saved: ["저장함", "Saved"], Stale: [ - "그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요", + "그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요", "Changed meanwhile — reloaded, please edit again", ], - Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"], - Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"], + Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"], + Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"], } as const satisfies Record; export type OptionsTextKey = keyof typeof TEXT; diff --git a/resources/tester/helper_m02_so_def_model.cjs b/resources/tester/helper_m02_so_def_model.cjs new file mode 100644 index 000000000..92969f79c --- /dev/null +++ b/resources/tester/helper_m02_so_def_model.cjs @@ -0,0 +1,112 @@ +/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const source = fs.readFileSync( + path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"), + "utf8", +); +const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] }); +const type = () => ({ + type_id: "k1", + group: "A", + name: "배수관", + options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }], +}); +const defOf = (over = {}) => ({ + 판: 3, + 구조물군: { A: "A군", B: "B군" }, + 동작: {}, + types: [ + type(), + { ...type(), type_id: "k2", name: "둘째" }, + { ...type(), type_id: "k3", group: "B", name: "셋째" }, + ], + 종류: {}, + 표시: {}, + 그룹: {}, + 배치: {}, + 규칙: [], + ...over, +}); +const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots)); + +const out = {}; +// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄 +out.auto = cells(type()); +// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음 +out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } }); +// 안 씀은 격자에서 빠짐 +const off = type(); +off.options[0].enabled = false; +out.off = cells(off); +// 겹침 +out.overlap = m + .overlaps( + m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }), + ) + .sort(); +// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위 +const rd = defOf(); +const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 }); +out.rules = [ + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }), + R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }), + R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }), + R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }), +].map((r) => m.ruleBad(rd, r)); +// 이름 — 빈 이름 · 겹침 +out.names = m.badNames(["가", " ", "가"]); +// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭 +out.clean = m.problems(defOf()); +const broken = defOf({ + 배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } }, +}); +broken.types[0].options[1].label = "a"; +broken.types[1].options[0].choices = []; +out.broken = m.problems(broken); +// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이 +const rn = defOf({ + 규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })], +}); +const a = rn.types[0].options[0]; +const b = rn.types[0].options[1]; +a.default = "x"; +m.renameChoice(rn, "k1", a, "x", "엑스"); +m.renameChoice(rn, "k1", b, "p", "피"); +out.rename = [ + a.choices, + a.default, + rn.규칙[0].만약.값들, + rn.규칙[0].그러면.선택지, + rn.규칙[0].그러면.기본값, +]; +m.dropChoice(rn, "k1", b, "피"); +out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면]; +// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만 +const od = defOf(); +out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)]; +out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)]; +out.base = [ + m.baseShown(defOf(), "k1"), + m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"), +]; +// 목록 순서 바꿈 · 끝은 그대로 +const list = [1, 2, 3]; +out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list]; +// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자 +const k = m.newKey("opt_", ["opt_aaaaaa"]); +out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_so_def_model.py b/resources/tester/test_m02_so_def_model.py new file mode 100644 index 000000000..6670f0937 --- /dev/null +++ b/resources/tester/test_m02_so_def_model.py @@ -0,0 +1,87 @@ +"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증. + +프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음. + - 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄 + (서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함. + - 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글. + - 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침. + - 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_model.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배치_자동_채움(r) -> None: + assert r["auto"] == { + "a": {"줄": 1, "칸": 1, "너비": 1}, + "b": {"줄": 1, "칸": 2, "너비": 1}, + "c": {"줄": 2, "칸": 1, "너비": 1}, + } + + +def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None: + assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2} + assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1} + assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1} + + +def test_안씀은_격자에서_빠짐(r) -> None: + assert set(r["off"]) == {"b", "c"} + assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1} + + +def test_겹침(r) -> None: + assert r["overlap"] == ["a", "b"] + + +def test_규칙_틀린_줄(r) -> None: + assert r["rules"] == [False, True, True, True, True, False, True] + + +def test_이름_검사(r) -> None: + assert r["names"] == ["(빈 이름)", "가"] + + +def test_저장_전_검사(r) -> None: + assert r["clean"] == [] + assert any("옵션 이름" in t for t in r["broken"]) + assert any("배치 겹침" in t for t in r["broken"]) + assert any("선택지가 있어야" in t for t in r["broken"]) + + +def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None: + assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"] + assert r["drop"] == [["q"], [], False] + + +def test_순서_기준측점_새키(r) -> None: + assert r["family"] == [False, True, ["B", "A"]] + assert r["type"] == [False, True, ["k2", "k1", "k3"]] + assert r["base"] == [True, False] + assert r["step"] == [False, True, [2, 1, 3]] + assert r["newKey"] == [True, True] From fc54a385df238a745806a0369d6360957410448a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:15:37 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat(B05):=2048-3=20=C2=B7=2048-4=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=EC=B9=B8=20?= =?UTF-8?q?=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=95=EC=9D=98=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로) - 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림 - 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로 - 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로 - 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸} - 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임 - 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- B05_Profile/B05_Profile_Api_Structures.ts | 27 ++- .../B05_Profile_UI_Structures_Define.ts | 216 ++++++++++++++++++ .../B05_Profile_UI_Structures_Define_Form.ts | 85 +++++++ .../B05_Profile_UI_Structures_Fields.ts | 7 +- B05_Profile/B05_Profile_UI_Structures_Form.ts | 14 ++ .../B05_Profile_UI_Structures_Groups.ts | 65 +++++- .../B05_Profile_UI_Structures_Panel.ts | 32 ++- .../B05_Profile_UI_Structures_Panel_Commit.ts | 28 ++- ...B05_Profile_UI_Structures_Panel_Options.ts | 123 ++++++++-- .../B05_Profile_UI_Structures_Summary.ts | 6 +- .../B05_Profile_UI_Style_Structures.css | 10 + .../tester/helper_b05_structures_define.cjs | 99 ++++++++ .../helper_b05_structures_form_summary.cjs | 10 +- .../tester/test_b05_structures_define.py | 80 +++++++ 14 files changed, 749 insertions(+), 53 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Define.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Define_Form.ts create mode 100644 resources/tester/helper_b05_structures_define.cjs create mode 100644 resources/tester/test_b05_structures_define.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 02146289b..81e5c579d 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -14,6 +14,13 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { + type DefinedRule, + type DefinedShape, + type DefinedSpot, + type StructureDefinition, + withDefinition, +} from "./B05_Profile_UI_Structures_Define"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ export type StructurePlacement = "point" | "interval" | "site"; @@ -27,7 +34,7 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; - /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ + /** 거짓 = 「안 씀」(48 정의 틀 3장) — 폼에 안 그림 · 옛 값은 그대로 남음. */ enabled?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ @@ -79,6 +86,16 @@ export interface StructureType { enabled: boolean; /** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */ groups?: StructureOptionGroup[]; + /** 구조물군 이름(48 정의 `구조물군`) — 없으면 화면 기본 이름. */ + group_name?: string; + /** 기준 측점 칸 보임(48 정의 `종류.기준측점`) — false 일 때만 가림. */ + show_anchor?: boolean; + /** 손코딩 특수 동작 이름(48 정의 `종류.동작`) — 그 동작이 붙은 칸 · 폼을 그대로 살림. */ + behaviors?: string[]; + /** 옵션 키 → 배치 칸(48 정의 `배치`). */ + spots?: Record | null; + /** 상위 → 하위 규칙(48 정의 `규칙` 중 이 종류 것). */ + rules?: DefinedRule[] | null; } /** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */ @@ -86,6 +103,8 @@ export interface StructureOptionGroup { id: string; name: string; options: string[]; + /** 그룹이 받을 형상 칸(48 정의 그룹 `형상`) — 길이 · 높이 · 전 · 후 받기 켬끔 + 뜻. */ + shape?: DefinedShape; } export interface StructureInstance { @@ -181,10 +200,14 @@ let typesCache: Promise | null = null; */ export function fetchStructureTypes(includeDisabled = false): Promise { if (!typesCache) { + // 구조물 정의(48) 덧칸 — 못 받으면 옛 화면 그대로(정의 없음 = 지금 차례 · 2열) + const definition = requestJson("/m02/structure-definition", { + method: "GET", + }).catch(() => null); typesCache = requestJson("/projects/structure-types", { method: "GET", }) - .then((payload) => payload.types) + .then(async (payload) => withDefinition(payload.types, await definition)) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts new file mode 100644 index 000000000..4b1b22660 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -0,0 +1,216 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Define.ts + * 구조물 정의 → 「구조물 배치」 칸 모양 (PLAN 48-3 · 48-4) — 화면 요소 없는 순수 규칙. + * + * 정의(`resources/master_template/구조물정의.json` · GET /structure-types 가 실어 줌)를 읽어 + * 칸 차례 · 격자 · 안 씀 · 규칙(상위 값 → 하위 선택지 · 보이기 · 기본값)을 낸다. 틀 = + * `docs/raw/M02_구조물옵션정의/정의_틀.md`. 선택지를 줄이면(숨김) 옛 값은 그 값만 남겨 보임. + * 그리기는 `_Structures_Panel_Options`(레지스트리 칸) · `_Structures_Define_Form`(시설 폼 칸). + * 값에 따른 계산 · 연동 제어는 여기 없음(48-5). + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +/** 정의가 옵션 하나에 실어 주는 몫 — 칸 그리기가 쓰는 것만. */ +export interface DefinedOption { + key: string; + label: string; + input: string; + choices: string[]; + default: string | number | null; + visible?: boolean; + /** 거짓 = 「안 씀」(정의 틀 3장) — 칸을 안 그림 · 옛 값은 그대로 남음. */ + enabled?: boolean; +} + +/** 상위 → 하위 규칙 한 줄(정의 `규칙`) — 상위 칸 값이 `만약.값들` 중 하나면 하위 칸에 `그러면` 을 씀. */ +export interface DefinedRule { + 종류?: string; + 만약: { 옵션: string; 값들: Array }; + 그러면: { + 옵션: string; + /** 하위 칸 선택지를 이 목록으로 좁힘. */ + 선택지?: string[]; + /** 하위 칸을 보이거나 가림. */ + 보이기?: boolean; + /** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */ + 기본값?: string | number | null; + }; +} + +/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */ +export interface DefinedSpot { + 줄?: number; + 칸?: number; + 너비?: number; +} + +export interface DefinedType { + options: DefinedOption[]; + /** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */ + show_anchor?: boolean; + /** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */ + spots?: Record | null; + rules?: DefinedRule[] | null; +} + +/** 칸 하나의 지금 모양 — 규칙을 건 뒤. */ +export interface OptionState { + visible: boolean; + choices: string[]; +} + +const whole = (n: unknown): number | null => + typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : null; + +/** 그릴 옵션 차례 — 배치가 있으면 (줄, 칸) 차례 · 없는 칸은 뒤에 정의 차례로. 안 씀 · 숨김은 뺌. */ +export function orderedOptions(type: { + options: T[]; + spots?: Record | null; +}): T[] { + const spots = type.spots ?? {}; + const rank = (key: string): [number, number] => { + const spot = spots[key]; + return [whole(spot?.줄) ?? Infinity, whole(spot?.칸) ?? Infinity]; + }; + return type.options + .map((option, index) => ({ option, index, at: rank(option.key) })) + .filter(({ option }) => option.enabled !== false && option.visible !== false) + .sort((a, b) => a.at[0] - b.at[0] || a.at[1] - b.at[1] || a.index - b.index) + .map(({ option }) => option); +} + +/** 한 줄 칸 수 — 배치가 쓰는 가장 오른쪽 칸(1 ~ 6) · 배치 없으면 2. */ +export function columnsOf(type: { spots?: Record | null }): number { + let widest = 0; + for (const spot of Object.values(type.spots ?? {})) { + const col = whole(spot.칸) ?? 1; + widest = Math.max(widest, col + (whole(spot.너비) ?? 1) - 1); + } + return widest ? Math.min(Math.max(widest, 2), 6) : 2; +} + +/** 칸 하나의 CSS 격자 자리 — 배치 없으면 null(흐름대로). */ +export function gridPlace( + spot: DefinedSpot | undefined, + columns: number, +): { row: string; column: string } | null { + if (!spot) return null; + const row = whole(spot.줄); + const col = whole(spot.칸); + const span = Math.min(whole(spot.너비) ?? 1, columns); + return { + row: row ? String(row) : "", + column: col ? `${Math.min(col, columns)} / span ${span}` : `span ${span}`, + }; +} + +/** 기준 측점 칸을 보일지 — 정의가 끈 때만 가림. */ +export function showsAnchor(type: { show_anchor?: boolean }): boolean { + return type.show_anchor !== false; +} + +const same = (a: unknown, b: unknown): boolean => String(a ?? "") === String(b ?? ""); + +/** 규칙을 지금 값에 건 칸 모양 — 걸린 줄의 선택지 · 보이기가 정의 기본을 덮음(뒤 줄이 이김). */ +export function ruleStates( + type: DefinedType, + values: Record, +): Map { + const states = new Map(); + for (const option of type.options) + states.set(option.key, { visible: true, choices: [...option.choices] }); + for (const rule of type.rules ?? []) { + const state = states.get(rule.그러면.옵션); + // 가려진 상위 칸에 매인 줄은 안 걸림 — 가림이 사슬로 이어짐(정의 차례대로 적용) + if (!state || states.get(rule.만약.옵션)?.visible === false) continue; + if (!hits(rule, values)) continue; + const pick = rule.그러면.선택지; + if (pick) state.choices = pick.filter((c) => state.choices.includes(c)); + if (typeof rule.그러면.보이기 === "boolean") state.visible = rule.그러면.보이기; + } + return states; +} + +const hits = (rule: DefinedRule, values: Record): boolean => + rule.만약.값들.some((value) => same(value, values[rule.만약.옵션])); + +/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */ +export function ruleDefaults( + type: DefinedType, + parent: string, + values: Record, +): Record { + const out: Record = {}; + for (const rule of type.rules ?? []) { + const value = rule.그러면.기본값; + if (rule.만약.옵션 !== parent || value === undefined || value === null) continue; + if (hits(rule, values)) out[rule.그러면.옵션] = value; + } + return out; +} + +/** 지금 정의에 없는 키의 값 — 「지난 정의 값」 읽기만 칸으로 보임. `known` = 폼이 아는 키 전부. */ +export function staleValues( + values: Record | undefined, + known: Iterable, +): Array<[string, string | number]> { + const keys = new Set(known); + return Object.entries(values ?? {}).filter(([key]) => !keys.has(key)); +} + +/** 그룹 형상 칸(정의 틀 4장) — 길이 · 높이 · 기준 측점 전 · 후 마다 받기 켬끔 + 뜻. */ +export type ShapeName = "길이" | "높이" | "전" | "후"; +export const SHAPE_FIELDS: ReadonlyArray<{ name: ShapeName; suffix: string; label: string }> = [ + { name: "길이", suffix: "length_m", label: "길이 (m)" }, + { name: "높이", suffix: "height_m", label: "높이 (m)" }, + { name: "전", suffix: "before_m", label: "기준측점 전 (m)" }, + { name: "후", suffix: "after_m", label: "기준측점 후 (m)" }, +]; +export type DefinedShape = Partial>; + +/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 id}__{칸}` 으로 둠. */ +export const shapeKey = (groupId: string, suffix: string): string => `${groupId}__${suffix}`; + +/** 그룹이 받는 형상 칸 — 받기 켠 것만 정해진 차례로. */ +export function shapeFields( + shape: DefinedShape | undefined, +): Array<{ suffix: string; label: string; meaning: string }> { + return SHAPE_FIELDS.filter((f) => shape?.[f.name]?.받기 === true).map((f) => ({ + suffix: f.suffix, + label: f.label, + meaning: shape?.[f.name]?.뜻 ?? "", + })); +} + +/** 정의 한 벌(GET /m02/structure-definition) 중 화면이 쓰는 칸. */ +export interface StructureDefinition { + 판?: number; + 구조물군?: Record; + 종류?: Record; + 그룹?: Record>; + 배치?: Record>; + 규칙?: DefinedRule[]; +} + +/** 종류 목록에 정의 덧칸을 얹음 — 구조물군 이름 · 기준 측점 · 동작 · 배치 · 규칙 · 그룹 형상. + * 정의를 못 받았으면(null) 그대로 — 지금 화면과 같음. */ +export function withDefinition< + T extends { type_id: string; group: string; groups?: Array<{ id: string }> }, +>(types: T[], definition: StructureDefinition | null): T[] { + if (!definition) return types; + return types.map((type) => { + const kind = definition.종류?.[type.type_id] ?? {}; + const shapes = new Map((definition.그룹?.[type.type_id] ?? []).map((g) => [g.id, g.형상])); + return { + ...type, + group_name: definition.구조물군?.[type.group], + show_anchor: kind.기준측점, + behaviors: kind.동작 ?? [], + spots: definition.배치?.[type.type_id] ?? null, + rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id), + ...(type.groups + ? { groups: type.groups.map((g) => ({ ...g, shape: shapes.get(g.id) })) } + : {}), + }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts new file mode 100644 index 000000000..e3e44d790 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Define_Form.ts + * 손코딩 시설 폼(동작 「계곡통과시설」)에 정의를 붙임(PLAN 48-3 · 48-4) — 숨긴 선택지 · 규칙. + * + * · 칸은 `labeled(..., 옵션키)` 가 붙인 `data-option-keys` 표식으로 찾음(키 하나인 칸만) · 찾는 자리 = + * 폼 본문 전체(그룹 상자로 옮겨 간 칸까지). + * · 칸 배치 · 이름은 손코딩 그대로(동작이 그림) — 안 씀은 끈 옵션과 같은 길(`_Drainage_Facility_Hidden`). + * · 선택지 = 정의 · 규칙 선택지 — 새 선택지는 보기를 더하고 밖 보기는 `hidden` 으로만 가림(옛 값은 그대로). + * 여러 키를 함께 적는 칸(유입구 구조 · 본체 규격)은 키 하나 표식이 아니라 안 건드림. + * · 규칙이 가린 칸 = `is-rule-hidden`(끈 옵션 표식 `is-option-hidden` 과 따로). + * ========================================================================== */ + +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { ruleDefaults, ruleStates } from "./B05_Profile_UI_Structures_Define"; +import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; + +/** 시설 폼이 가릴 옵션 키 — 「구조물 옵션 보이기」에서 끈 것 + 정의 「안 씀」. */ +export function hiddenOptionKeys(type: StructureType | null): string[] { + return (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key); +} + +export interface FacilityDefinition { + /** 지금 종류 정의로 칸 모양을 맞춤 — 시설 폼을 채운 뒤마다 부름. */ + apply: (type: StructureType | null) => void; +} + +export function createFacilityDefinition(root: HTMLElement): FacilityDefinition { + let type: StructureType | null = null; + const wrapOf = (key: string): HTMLElement | null => + [...root.querySelectorAll("[data-option-keys]")].find( + (node) => node.dataset.optionKeys === key, + ) ?? null; + const controlOf = (key: string): HTMLInputElement | HTMLSelectElement | null => + wrapOf(key)?.querySelector("select, input") ?? null; + const values = (): Record => + Object.fromEntries((type?.options ?? []).map((o) => [o.key, controlOf(o.key)?.value ?? ""])); + + const paint = (): void => { + if (!type) return; + const states = ruleStates(type, values()); + for (const option of type.options) { + const wrap = wrapOf(option.key); + const state = states.get(option.key); + if (!wrap || !state) continue; + wrap.classList.toggle("is-rule-hidden", !state.visible); + const control = controlOf(option.key); + if (!(control instanceof HTMLSelectElement)) continue; + // 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로) + const have = new Set([...control.options].map((item) => item.value)); + for (const choice of state.choices) + if (!have.has(choice)) control.add(new Option(choice, choice)); + for (const item of control.options) { + item.hidden = + !!item.value && !state.choices.includes(item.value) && item.value !== control.value; + } + } + }; + + // 상위 칸이 바뀌면 걸린 규칙의 기본값을 하위 칸에 넣고, 그 칸의 change 로 폼 반영(시설 폼 emit)을 잇는다. + root.addEventListener("change", (event) => { + if (!type) return; + const key = (event.target as HTMLElement).closest("[data-option-keys]")?.dataset + .optionKeys; + if (key) { + for (const [child, value] of Object.entries(ruleDefaults(type, key, values()))) { + const control = controlOf(child); + if (!control || control.value === String(value)) continue; + control.value = String(value); + control.dispatchEvent(new Event("change", { bubbles: true })); + } + } + paint(); + }); + + return { + apply(next) { + type = next; + root + .querySelectorAll(".is-rule-hidden") + .forEach((node) => node.classList.remove("is-rule-hidden")); + root.querySelectorAll("option[hidden]").forEach((o) => (o.hidden = false)); + paint(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 2581b79ee..ed31f4ce4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -74,7 +74,12 @@ export function optionControl( const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { const blank = option.required ? "— 선택 —" : "— 안 정함 —"; - const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); + // 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음) + const kept = value && !option.choices.includes(value) ? [value] : []; + const element = select([ + ["", blank], + ...[...option.choices, ...kept].map((c) => [c, c] as [string, string]), + ]); element.value = value; if (tip) element.title = tip; return element; diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 980e75b36..e4a177b27 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -24,6 +24,10 @@ import { import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; +import { + createFacilityDefinition, + type FacilityDefinition, +} from "./B05_Profile_UI_Structures_Define_Form"; export interface StructuresFormElements { root: HTMLElement; @@ -46,6 +50,10 @@ export interface StructuresFormElements { /** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */ actions: HTMLElement; facilityOptions: ReturnType; + /** 「지난 정의 값」 읽기만 상자(48-3) — 지금 정의에 없는 키의 값. */ + staleBox: HTMLElement; + /** 시설 폼(손코딩)에 정의 붙이기(48-3 · 48-4) — 숨긴 선택지 · 규칙. */ + facilityDefinition: FacilityDefinition; primary: HTMLButtonElement; removeButton: HTMLButtonElement; resetButton: HTMLButtonElement; @@ -148,6 +156,9 @@ export function buildStructuresForm(options: { const summary = buildSummaryRow(); summary.root.hidden = true; const groupBoxes = createGroupBoxes(); + const staleBox = document.createElement("fieldset"); + staleBox.className = "b05-structure__group b05-structure__stale"; + staleBox.hidden = true; body.append( typeRow, @@ -156,6 +167,7 @@ export function buildStructuresForm(options: { positionDivider, optionRow, facilityOptions.root, + staleBox, summary.root, groupBoxes.root, actions, @@ -178,6 +190,8 @@ export function buildStructuresForm(options: { optionRow, actions, facilityOptions, + staleBox, + facilityDefinition: createFacilityDefinition(body), primary, removeButton, resetButton, diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index 1f7fce79d..ea74cd5ad 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -10,6 +10,8 @@ * 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘). * · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄). * · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠. + * · 그룹 형상 칸(48 정의 그룹 `형상` · 길이 · 높이 · 전 · 후 받기 켬끔) — 값은 구조물 옵션 + * `{그룹 id}__{칸}` 으로 저장(패널이 옵션에 얹음) · 계산은 안 함(48-5). * ========================================================================== */ import { openItemPicker } from "@ui/ui_template_item_picker"; @@ -17,6 +19,9 @@ import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import type { StructureType } from "./B05_Profile_Api_Structures"; import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; +import { field, numberInput } from "./B05_Profile_UI_Structures_Fields"; import { GROUP_JOIN, type GroupItems, @@ -33,8 +38,20 @@ export interface GroupBoxes { set: (items: GroupItems) => void; /** 한 그룹 값 — null = 해제. */ setOne: (groupId: string, item: SummaryItem | null) => void; - /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ - bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void; + /** [고르기] · [해제] · 형상 칸 고침 배선 — 패널이 한 번 부른다. */ + bind: (handlers: GroupHandlers) => void; + /** 형상 칸 값 `{그룹 id}__{칸}: 수` — 빈 칸은 뺌. 옵션에 얹어 저장. */ + shapes: () => Record; + /** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */ + shapeKeys: (type?: StructureType | null) => string[]; + /** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */ + setShapes: (options: Record | undefined) => void; +} + +interface GroupHandlers { + pick: (groupId: string) => void; + clear: (groupId: string) => void; + change?: () => void; } export function createGroupBoxes(): GroupBoxes { @@ -43,7 +60,12 @@ export function createGroupBoxes(): GroupBoxes { root.dataset.b05Groups = ""; root.hidden = true; let items: GroupItems = {}; - let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null; + let handlers: GroupHandlers | null = null; + let shapes: Record = {}; + let current: StructureType | null = null; + const keysOf = (type: StructureType | null | undefined): string[] => + (type?.groups ?? []).flatMap((g) => SHAPE_FIELDS.map((f) => shapeKey(g.id, f.suffix))); + const shapeInputs = new Map(); const rows = new Map(); /** 옮긴 칸 · 제자리 표지 */ const moved: Array<{ node: HTMLElement; mark: Comment }> = []; @@ -83,13 +105,15 @@ export function createGroupBoxes(): GroupBoxes { render(type, scopes, facility) { restore(); rows.clear(); + shapeInputs.clear(); + current = type; const groups = type?.groups ?? []; const boxes: HTMLElement[] = []; bodies = []; // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) const off = hiddenFormKeys( facility, - (type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key), + (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), ); for (const group of groups) { const keys = hiddenFormKeys(facility, group.options); @@ -133,6 +157,19 @@ export function createGroupBoxes(): GroupBoxes { } } } + for (const shape of shapeFields(group.shape)) { + const key = shapeKey(group.id, shape.suffix); + const input = numberInput("0.1", "0"); + input.value = shapes[key] ?? ""; + input.dataset.shapeKey = key; + input.title = shape.meaning; + input.addEventListener("change", () => { + shapes = { ...shapes, [key]: input.value.trim() }; + handlers?.change?.(); + }); + shapeInputs.set(key, input); + body.append(field(shape.label, input)); + } if (!body.childElementCount) { const none = document.createElement("p"); none.className = "b05-structure__owner-note"; @@ -179,6 +216,23 @@ export function createGroupBoxes(): GroupBoxes { bind(next) { handlers = next; }, + shapes() { + const out: Record = {}; + for (const key of shapeInputs.keys()) { + const text = (shapes[key] ?? "").trim(); + if (text !== "" && Number.isFinite(Number(text))) out[key] = Number(text); + } + return out; + }, + shapeKeys: (type) => keysOf(type === undefined ? current : type), + setShapes(options) { + shapes = Object.fromEntries( + Object.entries(options ?? {}) + .filter(([key]) => key.includes(GROUP_JOIN)) + .map(([key, value]) => [key, String(value)]), + ); + for (const [key, input] of shapeInputs) input.value = shapes[key] ?? ""; + }, }; } @@ -193,6 +247,8 @@ export function bindSummaryPickers(opts: { editingId: () => string | null; selectedPipeChainage: () => number | null; commitSummary: () => void; + /** 형상 칸 고침 반영 — 고치는 중이면 곧바로(새 항목은 [추가]가 확정). */ + liveCommit: () => void; }): void { const base = (): string | null => { const pipe = opts.selectedPipeChainage(); @@ -214,6 +270,7 @@ export function bindSummaryPickers(opts: { }, }); opts.groups.bind({ + change: opts.liveCommit, pick: (groupId) => { const id = base(); void pick( diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 149903454..5731e515d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -23,7 +23,10 @@ import { import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; -import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; +import { + paintStale, + renderOptionFields as renderOptionFieldsInto, +} from "./B05_Profile_UI_Structures_Panel_Options"; import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { @@ -38,7 +41,8 @@ import { loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { showsAnchor } from "./B05_Profile_UI_Structures_Define"; +import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; @@ -86,6 +90,8 @@ export function createStructuresSection( optionRow, actions, facilityOptions, + staleBox, + facilityDefinition, primary, removeButton, resetButton, @@ -232,6 +238,8 @@ export function createStructuresSection( ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` : ""; ownerNote.hidden = !type?.design_owner; + // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 + anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; @@ -245,12 +253,12 @@ export function createStructuresSection( ): void { const kind = facilityFormKind(currentType()); facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow); + if (kind) + paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType())); // 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장) - facilityOptions.setHiddenOptions( - kind, - (currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), - ); + facilityOptions.setHiddenOptions(kind, hiddenOptionKeys(currentType())); regroup(); + facilityDefinition.apply(kind ? currentType() : null); // 숨긴 선택지 · 규칙(48-3 · 48-4) syncOptionLock(); } @@ -321,6 +329,8 @@ export function createStructuresSection( /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ function renderOptionFields(values?: Record): void { renderOptionFieldsInto(optionContext, values ?? newDefaults()); + if (!facilityFormKind(currentType())) + paintStale(staleBox, currentType(), values, null, groupBoxes.shapeKeys(currentType())); regroup(); } @@ -506,8 +516,7 @@ export function createStructuresSection( }, summary: () => summary.get(), setSummary: (value) => summary.set(value), - groupItems: () => groupBoxes.get(), - setGroupItems: (value) => groupBoxes.set(value), + groupBoxes, }; bindStructuresEvents(panelContext); // 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups` @@ -518,6 +527,7 @@ export function createStructuresSection( editingId: () => editingId, selectedPipeChainage: () => selectedPipeChainage, commitSummary, + liveCommit, }); body.insertBefore(field("메모", memoField), actions); syncButtons(); @@ -530,11 +540,11 @@ export function createStructuresSection( hasSelection: () => editingId !== null || selectedPipeChainage !== null, setTypes(next) { types = next; - const groups = [...new Set(next.map((t) => t.group))]; - // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. + const groups = new Map(next.map((t) => [t.group, t.group_name] as const)); + // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저. groupSelect.replaceChildren( new Option("— 선택 —", ""), - ...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)), + ...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)), ); groupSelect.value = ""; syncTypeOptions(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 86196870c..e24155203 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; +import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { readGroupItems, readSummaryItem, @@ -75,13 +76,13 @@ export interface StructuresCommitContext { summary: () => SummaryItem | null; setSummary: (value: SummaryItem | null) => void; /** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */ - groupItems: () => GroupItems; - setGroupItems: (value: GroupItems) => void; + /** 옵션 그룹 상자 — 집계 항목(46-3) · 형상 칸 값(48-3). */ + groupBoxes: GroupBoxes; } /** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */ function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } { - const items = ctx.groupItems(); + const items = ctx.groupBoxes.get(); return Object.keys(items).length ? { group_items: items } : {}; } @@ -105,7 +106,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(target.options); ctx.setSummary(readSummaryItem(target.summary_item)); - ctx.setGroupItems(readGroupItems(target.group_items)); + ctx.groupBoxes.set(readGroupItems(target.group_items)); + ctx.groupBoxes.setShapes(target.options); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { @@ -134,7 +136,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = ""; ctx.renderOptionFields(); ctx.setSummary(null); - ctx.setGroupItems({}); + ctx.groupBoxes.set({}); + ctx.groupBoxes.setShapes({}); ctx.syncFacilityForm(); } [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); @@ -182,7 +185,8 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte ctx.memoField.value = ""; ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); ctx.setSummary(readSummaryItem(pipe.summary_item)); - ctx.setGroupItems(readGroupItems(pipe.group_items)); + ctx.groupBoxes.set(readGroupItems(pipe.group_items)); + ctx.groupBoxes.setShapes(pipe.options); [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); @@ -191,9 +195,11 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte } export function readOptions(ctx: StructuresCommitContext): Record { - // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. - if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions(); - const values: Record = {}; + // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. 그룹 형상 칸(48-3)은 둘 다 얹음. + const shapes = ctx.groupBoxes.shapes(); + if (ctx.facilityFormKind(ctx.currentType())) + return { ...ctx.facilityOptions.readOptions(), ...shapes }; + const values: Record = { ...shapes }; ctx.optionInputs.forEach((input) => { // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. if (input.isEmpty()) return; @@ -241,7 +247,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; - const options = ctx.facilityOptions.readOptions(); + const options = readOptions(ctx); if (Object.keys(options).length) attributes.options = options; // 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다. attributes.summary_item = ctx.summary(); @@ -363,7 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis options: mergeStructureOptions( previous, type.type_id, - ctx.optionInputs.map((entry) => entry.key), + [...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()], records[0].options, ), }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index f557893d3..5a30bf77c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -11,7 +11,15 @@ import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { field, optionControl } from "./B05_Profile_UI_Structures_Fields"; -import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { + columnsOf, + gridPlace, + orderedOptions, + ruleDefaults, + ruleStates, + staleValues, +} from "./B05_Profile_UI_Structures_Define"; +import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { @@ -46,39 +54,48 @@ export function renderOptionFields( optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. + // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). const shown = type && !ctx.facilityFormKind(type) - ? type.options.filter((o) => (ctx.includeDetail || isB05Option(o)) && isOptionVisible(o)) + ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) : []; - if (!shown.length) { + optionRow.style.gridTemplateColumns = ""; + if (!type || !shown.length) { optionRow.hidden = true; ctx.syncOptionLock(); return; } optionRow.hidden = false; + const columns = columnsOf(type); + if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`; + // 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴) + const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = []; shown.forEach((option) => { // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). + // 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘. const input = optionControl(option, values[option.key]); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; - // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 - // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). - const locked = option.enabled === false; - if (locked && !input.value) input.value = String(option.default ?? ""); - if (locked) { - input.disabled = true; - input.classList.add("is-locked"); - input.title = "지금은 고를 수 없는 항목입니다."; - } const wrap = field(label, input, option.default_basis); wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) + // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 + const place = gridPlace(type.spots?.[option.key], columns); + wrap.style.gridRow = place?.row ?? ""; + wrap.style.gridColumn = place?.column ?? ""; optionRow.append(wrap); - input.addEventListener("change", () => ctx.liveCommit()); - optionInputs.push({ - key: option.key, - required: !locked && !!option.required, - input, - read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), - isEmpty: () => input.value.trim() === "", + // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) + input.addEventListener("change", () => { + applyRules(option.key); + ctx.liveCommit(); + }); + all.push({ + wrap, + entry: { + key: option.key, + required: !!option.required, + input, + read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), + isEmpty: () => input.value.trim() === "", + }, }); // 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을 // 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다. @@ -110,6 +127,74 @@ export function renderOptionFields( input.addEventListener("change", () => refresh(true)); } }); + /** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */ + function applyRules(parent: string | null): void { + const now = (): Record => + Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value])); + if (parent) { + for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) { + const hit = all.find(({ entry }) => entry.key === key); + if (hit) hit.entry.input.value = String(value); + } + } + const next = ruleStates(type!, now()); + optionInputs.length = 0; + for (const { entry, wrap } of all) { + const state = next.get(entry.key); + const off = state?.visible === false; + wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식 + if (!off) optionInputs.push(entry); + if (state && entry.input.tagName.toLowerCase() === "select") + syncChoices(entry.input as HTMLSelectElement, state.choices); + } + } + applyRules(null); ctx.syncOptionLock(); ctx.syncRangeDisplay(); } + +/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ +function syncChoices(element: HTMLSelectElement, choices: string[]): void { + const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value); + const keep = element.value && !choices.includes(element.value) ? [element.value] : []; + const want = [...choices, ...keep]; + if (now.length === want.length && now.every((v, i) => v === want[i])) return; + const value = element.value; + const blank = element.children[0]; + element.replaceChildren(blank, ...want.map((c) => new Option(c, c))); + element.value = value; +} + +/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은 + * 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */ +export function paintStale( + box: HTMLElement, + type: StructureType | null, + values: Record | undefined, + facility: PipeFacility | null, + /** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */ + shapeKeys: string[] = [], +): void { + const known = [ + ...shapeKeys, + ...(type?.options ?? []).map((o) => o.key), + ...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []), + ]; + const stale = type ? staleValues(values, known) : []; + box.replaceChildren(); + box.hidden = !stale.length; + if (!stale.length) return; + const legend = document.createElement("legend"); + legend.textContent = "지난 정의 값"; + const body = document.createElement("div"); + body.className = "b05-structure__grid"; + for (const [key, value] of stale) { + const input = document.createElement("input"); + input.type = "text"; + input.readOnly = true; + input.value = String(value); + input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임"; + body.append(field(key, input)); + } + box.append(legend, body); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary.ts b/B05_Profile/B05_Profile_UI_Structures_Summary.ts index 9503cd1af..9e29b082b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary.ts @@ -8,9 +8,9 @@ * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ -/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 일 때만 숨김. 없으면 보임. */ -export function isOptionVisible(option: { visible?: boolean }): boolean { - return option.visible !== false; +/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 이거나 정의가 「안 씀」(`enabled:false` · 48)일 때 숨김. */ +export function isOptionVisible(option: { visible?: boolean; enabled?: boolean }): boolean { + return option.visible !== false && option.enabled !== false; } /** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임. diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 57e713798..6b140043f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -515,3 +515,13 @@ .b05-structure__optgroup > .b05-structure__grid.is-locked { opacity: 0.5; } + +/* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */ +.b05-structure-section .is-rule-hidden { + display: none; +} + +/* 「지난 정의 값」 읽기만 상자(PLAN 48-3) — 지금 정의에 없는 키의 값 */ +.b05-structure__stale input[readonly] { + opacity: 0.7; +} diff --git a/resources/tester/helper_b05_structures_define.cjs b/resources/tester/helper_b05_structures_define.cjs new file mode 100644 index 000000000..c4249162b --- /dev/null +++ b/resources/tester/helper_b05_structures_define.cjs @@ -0,0 +1,99 @@ +/* 구조물 정의 → 칸 모양 단위검증 헬퍼(PLAN 48-3 · 48-4) — `B05_Profile_UI_Structures_Define.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌림(import 없는 파일). 결과는 표준출력에 JSON 한 벌. + * 판 1 정의(`resources/master_template/구조물정의.json`)도 읽어 지금 화면과 같은지(차례 · 2열) 봄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const src = fs.readFileSync( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), + "utf8", +); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const d = box.exports; + +const opt = (key, extra = {}) => ({ + key, + label: key, + input: "select", + choices: ["a", "b", "c"], + default: null, + ...extra, +}); +const type = { + options: [ + opt("p"), + opt("q"), + opt("r", { enabled: false }), + opt("s", { visible: false }), + opt("t"), + ], + spots: { t: { 줄: 1, 칸: 1, 너비: 2 }, p: { 줄: 2, 칸: 2 } }, + rules: [ + { + 종류: "x", + 만약: { 옵션: "p", 값들: ["a"] }, + 그러면: { 옵션: "q", 선택지: ["b", "c"], 기본값: "c" }, + }, + { 종류: "x", 만약: { 옵션: "p", 값들: ["b"] }, 그러면: { 옵션: "q", 보이기: false } }, + { 종류: "x", 만약: { 옵션: "q", 값들: ["c"] }, 그러면: { 옵션: "t", 보이기: false } }, + ], +}; +const plain = (states) => Object.fromEntries([...states].map(([k, v]) => [k, v])); + +const def = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const merged = d.withDefinition( + def.types.map((t) => ({ ...t, groups: (def.그룹[t.type_id] ?? []).map((g) => ({ id: g.id })) })), + def, +); +const v1 = merged.map((t) => ({ + id: t.type_id, + same: + JSON.stringify(d.orderedOptions(t).map((o) => o.key)) === + JSON.stringify(t.options.filter((o) => o.enabled !== false).map((o) => o.key)), + columns: d.columnsOf(t), + anchor: d.showsAnchor(t), +})); + +const out = { + order: d.orderedOptions(type).map((o) => o.key), + columns: d.columnsOf(type), + columnsNone: d.columnsOf({}), + placeT: d.gridPlace(type.spots.t, 2), + placeNone: d.gridPlace(undefined, 2), + stateA: plain(d.ruleStates(type, { p: "a", q: "c" })), + stateB: plain(d.ruleStates(type, { p: "b", q: "c" })), + defaultsA: d.ruleDefaults(type, "p", { p: "a" }), + defaultsB: d.ruleDefaults(type, "p", { p: "b" }), + stale: d.staleValues({ p: "a", old_key: 3, g1__length_m: 2 }, ["p", "g1__length_m"]), + shapes: d.shapeFields({ + 길이: { 받기: true, 뜻: "연장" }, + 높이: { 받기: false }, + 후: { 받기: true }, + }), + shapeKey: d.shapeKey("g1", "length_m"), + withNull: d.withDefinition([{ type_id: "a", group: "A" }], null), + withDef: d.withDefinition([{ type_id: "a", group: "A", groups: [{ id: "g1" }] }], { + 구조물군: { A: "가 군" }, + 종류: { a: { 기준측점: false, 동작: ["계곡통과시설"] } }, + 그룹: { a: [{ id: "g1", 형상: { 길이: { 받기: true } } }] }, + 배치: { a: { k: { 줄: 1, 칸: 1 } } }, + 규칙: [ + { ...type.rules[0], 종류: "a" }, + { ...type.rules[1], 종류: "b" }, + ], + }), + v1Types: v1.length, + v1Same: v1.every((t) => t.same), + v1Columns: [...new Set(v1.map((t) => t.columns))], + v1Anchor: v1.every((t) => t.anchor), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index f5e9f6ebb..0a548359e 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -355,8 +355,14 @@ async function saveWith({ setTempPipeChainage() {}, summary: () => summaryValue, setSummary: (value) => (summaryValue = value), - groupItems: () => ({}), // 옵션 그룹(PLAN 46-3) — 이 시험은 그룹 없음 - setGroupItems() {}, + // 옵션 그룹(PLAN 46-3) · 형상 칸(48-3) — 이 시험은 그룹 없음 + groupBoxes: { + get: () => ({}), + set() {}, + shapes: () => ({}), + shapeKeys: () => [], + setShapes() {}, + }, }; if (summaryOnly) { const done = commitModule.commitSummaryOnly(ctx); diff --git a/resources/tester/test_b05_structures_define.py b/resources/tester/test_b05_structures_define.py new file mode 100644 index 000000000..1cb497663 --- /dev/null +++ b/resources/tester/test_b05_structures_define.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""B05/B06 「구조물 배치」 칸 = 구조물 정의대로 — PLAN 48-3 · 48-4 (2026-10-05). + +정의(`구조물정의.json`)가 주는 차례 · 배치 격자 · 안 씀(`enabled:false`) · 규칙(상위 값 → 하위 +선택지 · 보이기 · 기본값) · 지난 정의 값 · 그룹 형상 칸 · 정의 덧칸 얹기를 본다. +판 1 정의로는 지금 화면과 같음(차례 그대로 · 2열 · 기준 측점 보임). +순수 규칙은 Node 헬퍼(`helper_b05_structures_define.cjs`)가 돌림 · 화면은 ORCA `ui_checks/48-3_구조물배치_정의그리기.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_structures_define.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_차례는_배치_줄_칸_뒤는_정의_차례_안씀_숨김_뺌(out): + assert out["order"] == ["t", "p", "q"] + + +def test_격자_칸수와_자리(out): + assert out["columns"] == 2 + assert out["columnsNone"] == 2 + assert out["placeT"] == {"row": "1", "column": "1 / span 2"} + assert out["placeNone"] is None + + +def test_규칙_선택지_보이기_사슬(out): + assert out["stateA"]["q"]["choices"] == ["b", "c"] + assert out["stateA"]["t"]["visible"] is False # q=c → t 가림 + assert out["stateB"]["q"]["visible"] is False # p=b → q 가림 + assert out["stateB"]["t"]["visible"] is True # 가려진 q 에 매인 줄은 안 걸림 + + +def test_규칙_기본값(out): + assert out["defaultsA"] == {"q": "c"} + assert out["defaultsB"] == {} + + +def test_지난_정의_값(out): + assert out["stale"] == [["old_key", 3]] + + +def test_그룹_형상_칸(out): + assert [s["suffix"] for s in out["shapes"]] == ["length_m", "after_m"] + assert out["shapes"][0]["meaning"] == "연장" + assert out["shapeKey"] == "g1__length_m" + + +def test_정의_덧칸_얹기(out): + assert out["withNull"] == [{"type_id": "a", "group": "A"}] + t = out["withDef"][0] + assert t["group_name"] == "가 군" + assert t["show_anchor"] is False + assert t["behaviors"] == ["계곡통과시설"] + assert t["spots"] == {"k": {"줄": 1, "칸": 1}} + assert [r["종류"] for r in t["rules"]] == ["a"] + assert t["groups"][0]["shape"] == {"길이": {"받기": True}} + + +def test_판1_정의는_지금_화면과_같음(out): + assert out["v1Types"] == 37 + assert out["v1Same"] is True + assert out["v1Columns"] == [2] + assert out["v1Anchor"] is True From 712d3f19d54d762f3d478275ffea55f1d80a3351 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:16:59 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix(ui):=2049=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=EC=A0=91=EB=8A=94=20=ED=8C=A8=EB=84=90=20=3D=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=96=91=EC=8B=9D=20=C2=B7=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=AA=B8=EB=A7=8C=20=EB=94=B0=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 49-1 공용 ui_template_side_panels — 세로 글 띠 · 접기 ▸ 걷어내고 공용 createWorkflowPanelHandle(side · right · 이름표) 손잡이를 패널 왼쪽 가장자리에 붙임 · 패널마다 손잡이 하나 · 하나만 열림 · 폭 공유 · 모두 접힘(폭 0 · 손잡이만 남음) — M01 로직 상세 · M02 일위대가 상세 · 면적산출 상세 모두 따라감 - 49-3 패널 묶음을 보이는 칸 안에 붙임(followView · 위 칸 overflow 가 CSS sticky 를 막아 JS) — 높이 = 보이는 칸 · 머리띠 아래 멈춤 · 몸만 스크롤 - 49-2 원문 md 읽기 — 빈 줄 · 주석이 붙은 표 둘을 가름(한 표로 합쳐지던 깨짐) · 칸 안
= 줄바꿈 - 49-4 원문 칸 80vh 제한 걷어냄 — 표 높이 = 내용 · 가로 넓은 표만 가로 스크롤 - 49-5 원문 꼴 한 벌 — M01 둘레 글자 크기(14) 안 따르고 본문 크기(M02 와 같음) - 확인 시나리오 ui_checks/49_오른쪽패널_양식.js · 시험 test_m01_logic_source 표 가름 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M01_MasterData/M01_MasterData_Store_Make.py | 12 +- .../M01_MasterData_UI_LogicLab_Side.css | 7 +- resources/tester/test_m01_logic_source.py | 8 + .../tester/ui_checks/49_오른쪽패널_양식.js | 194 ++++++++++++++++++ ui_template/ui_template_side_panels.css | 76 +++---- ui_template/ui_template_side_panels.ts | 84 +++++--- 6 files changed, 304 insertions(+), 77 deletions(-) create mode 100644 resources/tester/ui_checks/49_오른쪽패널_양식.js diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index c5ab2df45..029ce0d35 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -108,11 +108,14 @@ _NUMBER = re.compile(r"(? list[dict]: - """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌.""" + """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 · + 빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `
` = 줄바꿈.""" out: list[dict] = [] + joinable = False for line in lines: - text = line.strip() + text = re.sub(r"", "\n", line.strip()) if not text or text.startswith("