From 51b380b64dd565dd146d69e0f635be05b4bd0e35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 11:57:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EB=B3=B4=EC=9D=B4=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EB=A7=88=EB=8B=A4=20B05/B06=20=ED=8F=BC?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EC=9D=BC=20=EC=98=B5=EC=85=98=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=C2=B7=20=ED=91=9C=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EC=A2=8C=EC=B8=A1=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 resources/master_template/구조물옵션표시.json(숨김만 저장 · 없으면 모두 보임) - GET /api/projects/structure-types 옵션마다 visible · GET/PUT /api/m02/structure-options - M02 좌측 「구조물 옵션 보이기」 줄 → 메인 종류(군별) → 옵션 체크 · [저장] · [모두 보임] Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- B05_Profile/B05_Profile_Structures_Router.py | 15 +- .../M02_MasterTemplete_Api_Fetch.ts | 23 ++ .../M02_MasterTemplete_Options.py | 149 ++++++++++++ .../M02_MasterTemplete_Options.ts | 220 ++++++++++++++++++ .../M02_MasterTemplete_Options_Text.ts | 43 ++++ .../M02_MasterTemplete_Router.py | 19 ++ .../M02_MasterTemplete_UI_Side.ts | 12 +- .../M02_MasterTemplete_UI_Style.css | 70 ++++++ resources/master_template/구조물옵션표시.json | 1 + .../tester/test_m02_structure_options.py | 129 ++++++++++ 10 files changed, 670 insertions(+), 11 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Options.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Options.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts create mode 100644 resources/master_template/구조물옵션표시.json create mode 100644 resources/tester/test_m02_structure_options.py diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index f5396e6e..4a6403af 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -32,9 +32,6 @@ from B05_Profile.B05_Profile_Structures_Schema import ( StructureListResponse, StructureSaveRequest, StructureSaveResponse, - StructureTypesResponse, - load_structure_types, - registry_schema_version, ) from common_util.common_util_drainage_pipes import ( PipePoint, @@ -44,6 +41,7 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible logger = logging.getLogger(__name__) @@ -58,13 +56,10 @@ _PROJECT_PATH_MISSING = { } -@router.get("/structure-types", response_model=StructureTypesResponse) -async def read_structure_types() -> StructureTypesResponse: - """구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용).""" - return StructureTypesResponse( - schema_version=registry_schema_version(), - types=list(load_structure_types()), - ) +@router.get("/structure-types", response_model=TypesWithVisible) +async def read_structure_types() -> TypesWithVisible: + """구조물 타입 레지스트리 정본 + 옵션마다 `visible`(M02 옵션 보이기 · 폼 생성용).""" + return with_visible() async def _project_root(project_id: UUID) -> str | None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a1a560cb..2fc61e8a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -250,6 +250,29 @@ export const editMaterialCombo = ( export const deleteMaterialCombo = (키: string, 판: string): Promise => call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" }); +/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */ +export interface OptionKind { + type_id: string; + group: string; + name: string; + /** 거짓이면 B05 선택지에 안 나오는 종류 */ + enabled: boolean; + options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[]; +} + +/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */ +export interface OptionsDoc { + 판: string; + 표시: Record>; + 종류: OptionKind[]; +} + +export const fetchStructureOptions = (): Promise => call("/structure-options"); + +/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ +export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => + call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options.py b/M02_MasterTemplete/M02_MasterTemplete_Options.py new file mode 100644 index 00000000..8936080b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options.py @@ -0,0 +1,149 @@ +"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크. + +정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}` +— 없는 종류 · 키는 보임(파일이 없어도 모두 보임). +저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`. +옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`). +판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from pydantic import Field + +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureOptionField, + StructureType, + StructureTypesResponse, + 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 + +FILE = "구조물옵션표시.json" +logger = logging.getLogger(__name__) + + +class OptionWithVisible(StructureOptionField): + """폼 옵션 한 칸 + 보임 여부(서버가 채움).""" + + visible: bool = True + + +class TypeWithVisible(StructureType): + options: list[OptionWithVisible] = Field(default_factory=list) + + +class TypesWithVisible(StructureTypesResponse): + """`GET /structure-types` 응답 — 옵션마다 `visible` 칸.""" + + types: list[TypeWithVisible] + + +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) + + +def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool: + row = doc.get(type_id) + return not (isinstance(row, dict) and row.get(key) is False) + + +def with_visible() -> TypesWithVisible: + """레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만).""" + try: + doc = _load()[0] + except store.StoreError as e: + logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail) + doc = {} + types = [ + { + **t.model_dump(), + "options": [ + {**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options + ], + } + for t in load_structure_types() + ] + return TypesWithVisible.model_validate( + {"schema_version": registry_schema_version(), "types": types} + ) + + +def listing() -> dict[str, Any]: + """`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션.""" + doc, version = _load() + hidden: dict[str, dict[str, bool]] = {} + kinds = [] + for t in load_structure_types(): + if not t.options: + continue + off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)} + if off: + hidden[t.type_id] = off + kinds.append( + { + "type_id": t.type_id, + "group": t.group, + "name": t.name, + "enabled": t.enabled, + "options": [ + {"key": o.key, "label": o.label, "phase": o.phase, "required": o.required} + for o in t.options + ], + } + ) + return {"판": version, "표시": hidden, "종류": kinds} + + +def _clean(shown: Any) -> 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()} + errors: list[str] = [] + out: dict[str, dict[str, bool]] = {} + for type_id, row in shown.items(): + if type_id not in keys: + errors.append(f"없는 구조물 종류 「{type_id}」") + elif not isinstance(row, dict): + errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함") + else: + for key, value in row.items(): + if key not in keys[type_id]: + errors.append(f"「{type_id}」 에 없는 옵션 「{key}」") + elif not isinstance(value, bool): + errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함") + elif not value: + out.setdefault(type_id, {})[key] = False + if errors: + raise store.StoreError(422, {"errors": errors}) + return out + + +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) + return listing() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options.ts b/M02_MasterTemplete/M02_MasterTemplete_Options.ts new file mode 100644 index 00000000..3a8c998b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options.ts @@ -0,0 +1,220 @@ +/* ============================================================================= + * M02_MasterTemplete_Options.ts + * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에 + * 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) · + * [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). + * 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장). + * ========================================================================== */ + +import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; +import { + fetchStructureOptions, + saveStructureOptions, + StaleError, + type OptionKind, + type OptionsDoc, +} from "./M02_MasterTemplete_Api_Fetch"; +import { to } from "./M02_MasterTemplete_Options_Text"; + +export interface OptionsSectionOptions { + isAdmin: boolean; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface OptionsSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + 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", + text: to("Row"), + 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; + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + let doc: OptionsDoc; + try { + doc = await fetchStructureOptions(); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + draw(doc); + } + + /** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ + function draw(doc: OptionsDoc): void { + let version = doc.판; + 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))); + }; + load(doc.표시); + let kinds = doc.종류; + let current: OptionKind | null = null; + + 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 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 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 }), + ...(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 paintOptions = (): void => { + if (!current) { + optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); + return; + } + const kind = current; + optionList.replaceChildren( + el("h3", { className: "m02-matset__head", text: kind.name }), + ...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); + setDirty(true); + 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("label", { + className: "m02-opts__option", + children: [ + box, + el("span", { text: o.label }), + el("small", { className: "m02-opts__key", text: o.key }), + ...tags, + ], + }); + }), + ); + }; + + 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(); + setDirty(true); + paintKinds(); + paintOptions(); + }); + save.addEventListener("click", async () => { + const 표시: OptionsDoc["표시"] = {}; + for (const [id, keys] of hidden) + if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); + save.disabled = true; // 연달아 눌러도 한 번만 + try { + const done = await saveStructureOptions(version, 표시); + version = done.판; + load(done.표시); + setDirty(false); + showToast(to("Saved"), "success"); + paintKinds(); + paintOptions(); + } catch (error) { + if (error instanceof StaleError) { + showToast(to("Stale"), "error"); + try { + const fresh = await fetchStructureOptions(); + version = fresh.판; + kinds = fresh.종류; + load(fresh.표시); + setDirty(false); + current = kinds.find((k) => k.type_id === current?.type_id) ?? null; + paintKinds(); + paintOptions(); + } catch (again) { + showToast(to("Load_Failed", why(again)), "error"); + } + } 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); + paintKinds(); + paintOptions(); + opt.onOpen(); + mark(true); + } + + return { title: to("Title"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts new file mode 100644 index 00000000..365d8c3a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * M02_MasterTemplete_Options_Text.ts + * 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Title: ["구조물 옵션 보이기", "Structure options shown"], + Row: ["종류마다 보일 옵션 고르기", "Choose options per type"], + Screen: ["구조물 옵션 보이기", "Structure options shown"], + Note: [ + "체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임", + "Only checked options appear in the B05 · B06 forms — definitions and stored values stay", + ], + 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: [ + "필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음", + "Required — hiding it leaves the B05 form unable to set it", + ], + 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}"], +} as const satisfies Record; + +export type OptionsTextKey = keyof typeof TEXT; + +export function to(key: OptionsTextKey, value = ""): string { + const entry = TEXT[key]; + return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 2c85e265..8a4ebfe6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from M02_MasterTemplete import M02_MasterTemplete_Branch as branch from M02_MasterTemplete import M02_MasterTemplete_Matset as matset +from M02_MasterTemplete import M02_MasterTemplete_Options as options from M02_MasterTemplete import M02_MasterTemplete_Store as store router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"]) @@ -41,6 +42,11 @@ class MatsetBody(BaseModel): 줄: dict[str, Any] +class OptionsBody(BaseModel): + 판: str = "" # 옵션 표시 표 판 — 어긋나면 409 + 표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김 + + def _call(fn, *args): try: return fn(*args) @@ -127,6 +133,19 @@ def delete_material_combo(key: str, 판: str = "") -> dict: return _call(matset.delete, key, 판) +# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`) +@router.get("/structure-options") +def get_structure_options() -> dict: + """`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션.""" + return _call(options.listing) + + +@router.put("/structure-options") +def put_structure_options(body: OptionsBody) -> dict: + """고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`.""" + return _call(options.save, body.표시, body.판) + + # 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로 @router.post("/structures/{column}/copy") def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict: diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 18121691..f765c1f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -36,6 +36,7 @@ import { type StructureColumn, } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; +import { buildOptionsSection } from "./M02_MasterTemplete_Options"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -122,13 +123,20 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => paint(), + onOpen: () => (options.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (matsetSection.classList.contains("is-collapsed")) void matset.refresh(); }); + // 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크) + const options = buildOptionsSection({ + isAdmin: canEdit(layer, opt.isAdmin), + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (matset.clear(), paint()), + }); // 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) const summary = section( L("M02_KindTable"), @@ -142,6 +150,7 @@ export function buildSide(opt: SideOptions): SideHandle { className: "m02-side__lists ui-sidebar-panel", children: [ summary, + section(options.title, options.body), section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), ], }); @@ -217,6 +226,7 @@ export function buildSide(opt: SideOptions): SideHandle { paintColumns(); paintButtons(); if (opt.getOpen()) matset.clear(); + if (opt.getOpen()) options.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index ac566224..57c1705e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -123,3 +123,73 @@ color: var(--color-text); font-size: var(--text-body); } + +/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */ +.m02-opts { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + max-width: 64rem; + font-size: var(--text-body-sm); +} + +.m02-opts__panes { + display: grid; + grid-template-columns: minmax(14rem, 20rem) 1fr; + gap: var(--spacing-16); + align-items: start; +} + +.m02-opts__kinds, +.m02-opts__list { + max-height: calc(100vh - 20rem); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-opts__group { + margin: var(--spacing-8) 0 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-opts__kind { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-opts__kind:hover { + background: var(--color-mist-violet); +} + +.m02-opts__kind.is-active { + border-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.m02-opts__option { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; + cursor: pointer; +} + +.m02-opts__key { + color: var(--color-text-muted); + font-size: 11px; +} diff --git a/resources/master_template/구조물옵션표시.json b/resources/master_template/구조물옵션표시.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/resources/master_template/구조물옵션표시.json @@ -0,0 +1 @@ +{} diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py new file mode 100644 index 00000000..e856d406 --- /dev/null +++ b/resources/tester/test_m02_structure_options.py @@ -0,0 +1,129 @@ +"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`). + +못박는 것 + - 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible. + - 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜. + - 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409. + - 파일이 깨져도 폼 응답은 모두 보임(죽지 않음). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +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_Options as opt +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router + +ROOT = Path(__file__).resolve().parents[2] +URL = "/api/m02/structure-options" +TYPES = "/api/projects/structure-types" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(m02_router) + app.include_router(b05_router) + return TestClient(app) + + +def _pick() -> tuple[str, str, str]: + """옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키).""" + t = next(t for t in load_structure_types() if len(t.options) >= 2) + return t.type_id, t.options[0].key, t.options[1].key + + +def _visible(client: TestClient) -> dict[str, dict[str, bool]]: + types = client.get(TYPES).json()["types"] + 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")) + == {} + ) + got = client.get(URL).json() + assert got["판"] == "" and got["표시"] == {} # 파일 없음 + assert all(all(v.values()) for v in _visible(client).values()) + assert client.get(TYPES).json()["schema_version"] == 1 + + +def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None: + tid, off, on = _pick() + before = client.get(TYPES).json()["types"] + res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}}) + assert res.status_code == 200, res.text + assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김 + seen = _visible(client) + assert seen[tid][off] is False and seen[tid][on] is True + assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로 + after = client.get(TYPES).json()["types"] + strip = lambda ts: [ # noqa: E731 + {**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]} + for t in ts + ] + assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜 + kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid) + assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0] + + +def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None: + tid, off, _ = _pick() + v1 = client.put(URL, json={"판": "", "표시": {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 all(all(v.values()) for v in _visible(client).values()) + + +def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None: + tid, off, _ = _pick() + for bad in ( + {"없는종류": {off: False}}, + {tid: {"없는키": False}}, + {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={"판": "낡음", "표시": {}}).status_code == 409 + assert client.put(URL, json={"판": "", "표시": {}}).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 + assert ( + side.index(" summary,") + < side.index("section(options.title") + < side.index('section(L("M02_KindDrawing")') + ) + assert "onOpen: () => (options.clear(), paint())" in side # 자재 조합을 열면 이 줄 고름 지움 + assert "onOpen: () => (matset.clear(), paint())" in side # 이 줄을 열면 자재 조합 고름 지움 + paint = side.split("function paint(): void {")[1].split("\n }\n")[0] + assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 + api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") + assert ( + 'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api + ) + ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8") + assert 'o.phase === "detail"' in ui # B06 전용 표시 From 927e85d1107a6ac072821e414110343540b1709e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 12:00:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui=5Ftemplate):=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=82=98=EB=AC=B4=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20PLAN=2021-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_tree: M01 renderTree 를 공용으로 올림(M01 은 같은 이름으로 그대로 부름) · 기본 줄 createTreeRow · 클래스 ui-tree__* - ui_template_item_picker: openItemPicker({current, canCopy}) → apply(summary_item) | clear | cancel · 왼쪽 마스터 · 회사 · 개인 → 종류 → 분류1 → 항목 나무 + 찾기 · 오른쪽 폴더 모양(키는 툴팁) · 개인용으로 복사 · 관리 화면 이동 - 목록 GET /api/m02/items?all=1 · 복사 POST /api/m02/layers/personal/items/copy — 서버가 없으면(404) 21-1 모양 가짜 응답 - 자료 쪽(나무 · 찾기 · 폴더 줄)은 화면 없는 모듈로 나눠 시험 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- M01_MasterData/M01_MasterData_UI_Side.ts | 2 +- M01_MasterData/M01_MasterData_UI_Style.css | 18 -- M01_MasterData/M01_MasterData_UI_Tree.ts | 42 +-- resources/tester/ui/item-picker-data.test.ts | 139 +++++++++ ui_template/ui_template_item_picker.css | 101 +++++++ ui_template/ui_template_item_picker.ts | 289 +++++++++++++++++++ ui_template/ui_template_item_picker_data.ts | 161 +++++++++++ ui_template/ui_template_item_picker_fake.ts | 60 ++++ ui_template/ui_template_locale_m2.ts | 23 ++ ui_template/ui_template_tree.css | 23 ++ ui_template/ui_template_tree.ts | 76 +++++ 11 files changed, 875 insertions(+), 59 deletions(-) create mode 100644 resources/tester/ui/item-picker-data.test.ts create mode 100644 ui_template/ui_template_item_picker.css create mode 100644 ui_template/ui_template_item_picker.ts create mode 100644 ui_template/ui_template_item_picker_data.ts create mode 100644 ui_template/ui_template_item_picker_fake.ts create mode 100644 ui_template/ui_template_tree.css create mode 100644 ui_template/ui_template_tree.ts diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 2e31508d..4fe29ef8 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -178,7 +178,7 @@ export function buildSide( children: [ el("span", { children: [ - el("span", { className: "m01-tree__caret", text: caret }), + el("span", { className: "ui-tree__caret", text: caret }), el("span", { text: node.label }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 2b909b1b..61c5c965 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -17,24 +17,6 @@ color: var(--color-accent); } -.m01-tree__kids { - display: flex; - flex-direction: column; - gap: 2px; -} - -.m01-tree__branch { - display: flex; - flex-direction: column; - gap: 2px; -} - -.m01-tree__caret { - display: inline-block; - width: var(--spacing-12); - color: var(--color-text-muted); -} - .m01-side__muted, .m01-master__muted { color: var(--color-text-muted); diff --git a/M01_MasterData/M01_MasterData_UI_Tree.ts b/M01_MasterData/M01_MasterData_UI_Tree.ts index 21d2c5f9..5fcb4df8 100644 --- a/M01_MasterData/M01_MasterData_UI_Tree.ts +++ b/M01_MasterData/M01_MasterData_UI_Tree.ts @@ -1,44 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_Tree.ts - * 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시 + * 왼쪽 패널 트리 — 공용 `ui_template_tree` 를 그대로 다시 내보냄(부르는 쪽 import 불변) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; - -export interface TreeNode { - id: string; - label: string; - /** 줄 수 — null 이면 안 보임 */ - count: number | null; - /** 눌렀을 때 — 없으면 접기/펴기만 */ - run?: () => void; - children?: TreeNode[]; - /** 처음부터 펴짐 */ - open?: boolean; -} - -/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */ -export type MakeRow = ( - node: TreeNode, - caret: string, - depth: number, - onClick: () => void, -) => HTMLElement; - -export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] { - return nodes.map((node) => { - if (!node.children?.length) return make(node, "", depth, () => node.run?.()); - const kids = el("div", { - className: "m01-tree__kids", - children: renderTree(node.children, make, depth + 1), - }); - kids.hidden = !node.open; - const row = make(node, kids.hidden ? "▸" : "▾", depth, () => { - kids.hidden = !kids.hidden; - const caret = row.querySelector(".m01-tree__caret"); - if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; - node.run?.(); - }); - return el("div", { className: "m01-tree__branch", children: [row, kids] }); - }); -} +export { renderTree, type MakeRow, type TreeNode } from "@ui/ui_template_tree"; diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts new file mode 100644 index 00000000..4b4f04d0 --- /dev/null +++ b/resources/tester/ui/item-picker-data.test.ts @@ -0,0 +1,139 @@ +/** + * 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양). + * 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts + */ +import assert from "node:assert/strict"; +import { + buildTree, + filterRows, + folderLines, + isCurrent, + pathIds, + rowId, + toSummary, + type TreeLabels, +} from "../../../ui_template/ui_template_item_picker_data.ts"; +import { fakeCopy, fakeItems } from "../../../ui_template/ui_template_item_picker_fake.ts"; + +const labels: TreeLabels = { + layers: { system: "마스터", company: "회사", personal: "개인" }, + unclassified: "분류 없음", +}; +const rows = fakeItems(); + +// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎 +const tree = buildTree(rows, labels); +assert.deepEqual( + tree.map((g) => [g.label, g.count]), + [ + ["마스터", 5], + ["회사", 2], + ["개인", 1], + ], +); +const system = tree[0]; +assert.deepEqual( + system.children?.map((k) => [k.label, k.count]), + [ + ["돌기슭막이", 3], + ["콘크리트포장", 1], + ["관보호공", 1], + ], +); +const cat = system.children?.[0].children?.[0]; +assert.equal(cat?.label, "메쌓기"); +assert.equal(cat?.count, 2); +const leaf = cat?.children?.[0]; +assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유"); +assert.equal(leaf?.tip, "SS000001"); // 키는 툴팁 +assert.equal(leaf?.count, null); +assert.ok(leaf?.row); + +// 분류가 빈 항목은 「분류 없음」 한 칸에 모임 +const company = tree[1]; +assert.equal(company.children?.[1].children?.[0].label, "분류 없음"); + +// 빈 그룹도 한 줄은 남음 +const onlySystem = buildTree( + rows.filter((r) => r.layer === "system"), + labels, +); +assert.deepEqual( + onlySystem.map((g) => [g.id, g.count, g.children?.length]), + [ + ["system", 5, 3], + ["company", 0, 0], + ["personal", 0, 0], + ], +); + +// 잎 id 는 층 · 주인 · 키 — 같은 키라도 층이 다르면 다른 줄 +const twin = { ...rows[0], layer: "company" as const, owner: "샘플회사" }; +assert.notEqual(rowId(rows[0]), rowId(twin)); + +// 찾기 — 이름 · 키 · 연결 이름 · 여러 낱말은 모두 들어 있어야 함 · 빈 글은 전부 +assert.equal(filterRows(rows, "").length, rows.length); +assert.equal(filterRows(rows, " ").length, rows.length); +assert.deepEqual( + filterRows(rows, "옹벽").map((r) => r.key), + ["SS000102"], +); +assert.deepEqual( + filterRows(rows, "ss000004").map((r) => r.key), + ["SS000004"], +); +assert.deepEqual( + filterRows(rows, "돌기슭막이 개인").map((r) => r.key), + ["SS000201"], +); +assert.equal(filterRows(rows, "없는이름").length, 0); + +// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인 +assert.deepEqual(toSummary(rows[0]), { + layer: "system", + owner: null, + key: "SS000001", + name: "돌기슭막이 메쌓기 1.2m 기초유", +}); +const mine = rows.find((r) => r.layer === "personal"); +assert.ok(mine); +assert.deepEqual(toSummary(mine), { + layer: "personal", + owner: "샘플회사/사용자", + key: "SS000201", + name: "돌기슭막이 메쌓기 1.2m(개인)", +}); +// layer 없는 줄(옛 서버)은 마스터로 봄 +assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system"); + +// 지금 지정된 항목인지 +assert.ok(isCurrent(toSummary(mine), mine)); +assert.ok(!isCurrent(toSummary(mine), rows[0])); +assert.ok(!isCurrent(null, rows[0])); + +// 처음 펼칠 자리 +assert.deepEqual(pathIds(rows[0], labels), [ + "system", + "system|돌기슭막이", + "system|돌기슭막이|메쌓기", +]); + +// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글 +const lines = folderLines(rows[0]); +assert.deepEqual( + lines.map((l) => l.kind), + ["drawing", "area", "material", "combo", "unitPrice"], +); +assert.equal(lines[0].text, "돌기슭막이 메쌓기 1.2m 기초유"); +assert.equal(lines[0].tip, "SD000001"); +assert.equal(folderLines({ key: "SS1", 열: "빈 항목" })[3].text, ""); + +// 가짜 복사 — 새 개인 줄이 생기고 키가 겹치지 않음 +const before = fakeItems(); +const copied = fakeCopy(before[0]); +const after = fakeItems(); +assert.equal(after.length, before.length + 1); +assert.equal(copied.layer, "personal"); +assert.equal(new Set(after.map(rowId)).size, after.length); + +console.log("항목 고르기 자료 시험 통과"); diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css new file mode 100644 index 00000000..8bcfc3d5 --- /dev/null +++ b/ui_template/ui_template_item_picker.css @@ -0,0 +1,101 @@ +/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */ +.ui-item-picker { + width: min(900px, 94vw); +} + +.ui-item-picker .ui-modal__body { + height: min(60vh, 520px); +} + +.ui-item-picker__cols { + display: grid; + grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr); + gap: var(--spacing-12); + flex: 1; + min-height: 0; +} + +.ui-item-picker__left { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; + min-width: 0; +} + +.ui-item-picker__tree { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: auto; +} + +.ui-item-picker__detail { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + min-height: 0; + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: auto; +} + +.ui-item-picker__name { + margin: 0; + font-size: var(--text-body); + color: var(--color-text-heading); + overflow-wrap: anywhere; +} + +.ui-item-picker__where, +.ui-item-picker__hint { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-item-picker__hint { + padding: var(--spacing-8); +} + +.ui-item-picker__folder { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 0 0 0 var(--spacing-12); + border-left: 2px solid var(--color-border); + list-style: none; +} + +.ui-item-picker__folder li { + display: flex; + gap: var(--spacing-8); + align-items: baseline; + min-width: 0; + color: var(--color-text-body); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.ui-item-picker__folder-label { + flex: 0 0 5.5em; + color: var(--color-text-muted); +} + +.ui-item-picker__none { + color: var(--color-text-muted); +} + +.ui-item-picker__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts new file mode 100644 index 00000000..7cc780b0 --- /dev/null +++ b/ui_template/ui_template_item_picker.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * ui_template_item_picker.ts + * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. + * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). + * + * const result = await openItemPicker({ current, canCopy }); + * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" + * ========================================================================== */ + +import "./ui_template_item_picker.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, createInputField, el, showToast } from "./ui_template_elements"; +import { fakeCopy, fakeItems } from "./ui_template_item_picker_fake"; +import { + buildTree, + filterRows, + folderLines, + isCurrent, + layerOf, + ownerOf, + pathIds, + rowId, + toSummary, + type FolderKind, + type PickNode, + type PickerRow, + type SummaryItem, + type TreeLabels, +} from "./ui_template_item_picker_data"; +import { t } from "./ui_template_locale"; +import { openModal } from "./ui_template_modal"; +import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; + +export type { SummaryItem } from "./ui_template_item_picker_data"; + +export interface ItemPickerOptions { + /** 지금 지정된 항목 — 없으면 null */ + current: SummaryItem | null; + /** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */ + canCopy: boolean; +} + +export type ItemPickerResult = + { action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" }; + +const MANAGE_HASH = "#/m02-master-template?layer=personal"; + +const FOLDER_LABEL: Record string> = { + drawing: () => t("M02_PickerDrawing"), + area: () => t("M02_PickerArea"), + material: () => t("M02_PickerMaterial"), + combo: () => t("M02_PickerCombo"), + unitPrice: () => t("M02_PickerUnitPrice"), +}; + +const labels = (): TreeLabels => ({ + layers: { + system: t("M02_PickerSystem"), + company: t("M02_PickerCompany"), + personal: t("M02_PickerPersonal"), + }, + unclassified: t("M02_PickerNoClass"), +}); + +/** 서버가 막은 까닭 그대로 */ +function reasonOf(body: unknown, status: number): string { + const detail = (body as { detail?: unknown } | null)?.detail; + return typeof detail === "string" && detail ? detail : `HTTP ${status}`; +} + +/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */ +async function loadRows(): Promise { + const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" }); + if (response.status === 404 || response.status === 405) return fakeItems(); + const body = (await response.json().catch(() => null)) as + PickerRow[] | { items?: PickerRow[]; detail?: unknown } | null; + if (!response.ok) throw new Error(reasonOf(body, response.status)); + return Array.isArray(body) ? body : (body?.items ?? []); +} + +/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */ +async function copyToPersonal(row: PickerRow): Promise { + const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }), + }); + if (response.status === 404 || response.status === 405) return fakeCopy(row).key; + const body = (await response.json().catch(() => null)) as { key?: string } | null; + if (!response.ok) throw new Error(reasonOf(body, response.status)); + return body?.key ?? null; +} + +const message = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export function openItemPicker(opts: ItemPickerOptions): Promise { + return new Promise((resolve) => { + let rows: PickerRow[] = []; + let chosen: PickerRow | null = null; + /** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */ + const opened = new Set(); + const leafButtons = new Map(); + + const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true }); + const treeHost = el("div", { className: "ui-item-picker__tree" }); + const detail = el("div", { className: "ui-item-picker__detail" }); + const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" }); + const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" }); + const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" }); + const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" }); + let close = (): void => {}; + + const finish = (result: ItemPickerResult): void => { + resolve(result); + close(); + }; + + const hint = (text: string): HTMLElement => + el("p", { className: "ui-item-picker__hint", text }); + + const paintDetail = (): void => { + if (!chosen) { + detail.replaceChildren(hint(t("M02_PickerHint"))); + } else { + const where = [labels().layers[layerOf(chosen)], chosen.종류, chosen.분류1] + .filter(Boolean) + .join(" › "); + const small = (text: string): HTMLElement => + el("p", { className: "ui-item-picker__where", text }); + detail.replaceChildren( + el("h4", { + className: "ui-item-picker__name", + text: chosen.열, + attrs: { title: chosen.key }, + }), + small(where), + ...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []), + el("ul", { + className: "ui-item-picker__folder", + children: folderLines(chosen).map((line) => + el("li", { + attrs: line.tip ? { title: line.tip } : {}, + children: [ + el("span", { + className: "ui-item-picker__folder-label", + text: FOLDER_LABEL[line.kind](), + }), + el("span", { + className: line.text ? "" : "ui-item-picker__none", + text: line.text || t("M02_PickerNone"), + }), + ], + }), + ), + }), + ); + } + const layer = chosen ? layerOf(chosen) : null; + applyButton.disabled = !chosen; + clearButton.disabled = !opts.current; + copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy); + manageButton.hidden = !(chosen && layer === "personal"); + }; + + const choose = (row: PickerRow): void => { + chosen = row; + for (const [id, button] of leafButtons) + button.classList.toggle("is-active", id === rowId(row)); + paintDetail(); + }; + + const toTree = (node: PickNode, searching: boolean): TreeNode => ({ + id: node.id, + label: node.label, + count: node.count, + tip: node.tip, + open: searching || opened.has(node.id), + run: node.row ? () => choose(node.row as PickerRow) : undefined, + children: node.children?.map((child) => toTree(child, searching)), + }); + + const paintTree = (): void => { + leafButtons.clear(); + const text = search.input.value; + const searching = text.trim() !== ""; + const groups = buildTree(filterRows(rows, text), labels()) + // 찾는 중에는 결과가 없는 그룹을 감춤 + .filter((group) => !searching || (group.count ?? 0) > 0) + .map((group) => toTree(group, searching)); + if (!groups.length) { + treeHost.replaceChildren(hint(t("M02_PickerNoMatch"))); + return; + } + treeHost.replaceChildren( + ...renderTree(groups, (node, caret, depth, onClick) => { + const button = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id); + else opened.delete(node.id); + }); + if (node.run) { + leafButtons.set(node.id, button); + if (chosen && node.id === rowId(chosen)) button.classList.add("is-active"); + } + return button; + }), + ); + }; + + const load = async (pickKey: string | null): Promise => { + treeHost.replaceChildren(hint(t("M02_PickerLoading"))); + try { + rows = await loadRows(); + } catch (error) { + treeHost.replaceChildren( + hint(t("M02_PickerLoadFailed").replace("{value}", message(error))), + ); + return; + } + const start = pickKey + ? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey) + : rows.find((row) => isCurrent(opts.current, row)); + if (start) { + for (const id of pathIds(start, labels())) opened.add(id); + chosen = start; + } else { + opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침 + } + paintTree(); + paintDetail(); + treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" }); + }; + + search.input.addEventListener("input", paintTree); + applyButton.addEventListener( + "click", + () => chosen && finish({ action: "apply", item: toSummary(chosen) }), + ); + clearButton.addEventListener("click", () => finish({ action: "clear" })); + manageButton.addEventListener("click", () => { + finish({ action: "cancel" }); + location.hash = MANAGE_HASH; + }); + copyButton.addEventListener("click", async () => { + if (!chosen) return; + copyButton.disabled = true; + try { + const key = await copyToPersonal(chosen); + showToast(t("M02_PickerCopied"), "success"); + await load(key); + } catch (error) { + showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error"); + } finally { + copyButton.disabled = false; + } + }); + + close = openModal({ + title: t("M02_PickerTitle"), + closeLabel: t("M02_Close"), + dialogClass: "ui-item-picker", + // 닫기 단추 · Esc · 바깥 누르기 = 취소 + beforeClose: () => { + resolve({ action: "cancel" }); + return true; + }, + mount: (body) => { + body.append( + el("div", { + className: "ui-item-picker__cols", + children: [ + el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), + detail, + ], + }), + el("div", { + className: "ui-item-picker__actions", + children: [copyButton, manageButton, clearButton, applyButton], + }), + ); + paintDetail(); + void load(null); + }, + }).close; + }); +} diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts new file mode 100644 index 00000000..8974b363 --- /dev/null +++ b/ui_template/ui_template_item_picker_data.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * ui_template_item_picker_data.ts + * 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`. + * 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`. + * ========================================================================== */ + +export type PickerLayer = "system" | "company" | "personal"; + +/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */ +export interface SummaryItem { + layer: PickerLayer; + owner: string | null; + key: string; + name: string; +} + +/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */ +export interface PickerRow { + key: string; + 열: string; + 종류?: string; + 분류1?: string; + 분류2?: string; + 분류3?: string; + 단위?: string; + 일위대가?: string; + 자재조합?: string; + 도번?: string; + 면적번?: string; + 재료번?: string; + layer?: PickerLayer; + owner?: string | null; +} + +/** 그룹 차례 = 마스터 · 회사 · 개인 */ +export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"]; + +export interface PickNode { + id: string; + /** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */ + label: string; + /** 마우스를 올렸을 때 — 잎은 키 */ + tip?: string; + /** 아래 잎 수 — 잎은 null */ + count: number | null; + children?: PickNode[]; + /** 잎이면 그 항목 */ + row?: PickerRow; +} + +export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system"; +export const ownerOf = (row: PickerRow): string | null => + layerOf(row) === "system" ? null : (row.owner ?? null); + +/** 같은 항목의 자리표 — 층 · 주인 · 키 */ +export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; + +export function toSummary(row: PickerRow): SummaryItem { + return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: row.열 }; +} + +export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean { + return ( + !!current && + current.layer === layerOf(row) && + (current.owner ?? null) === ownerOf(row) && + current.key === row.key + ); +} + +/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */ +export function filterRows(rows: PickerRow[], text: string): PickerRow[] { + const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return rows; + return rows.filter((row) => { + const hay = [ + row.열, + row.종류, + row.분류1, + row.분류2, + row.분류3, + row.key, + row.일위대가, + row.자재조합, + ] + .join(" ") + .toLowerCase(); + return words.every((word) => hay.includes(word)); + }); +} + +export interface TreeLabels { + layers: Record; + /** 종류 · 분류1 이 비었을 때 */ + unclassified: string; +} + +/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ +export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { + return PICKER_LAYERS.map((layer) => { + const mine = rows.filter((row) => layerOf(row) === layer); + const kinds = new Map>(); + for (const row of mine) { + const kind = row.종류 || labels.unclassified; + const cat = row.분류1 || labels.unclassified; + const cats = kinds.get(kind) ?? new Map(); + cats.set(cat, [...(cats.get(cat) ?? []), row]); + kinds.set(kind, cats); + } + const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => { + const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({ + id: `${layer}|${kind}|${cat}`, + label: cat, + count: leaves.length, + children: leaves.map((row) => ({ + id: rowId(row), + label: row.열, + tip: row.key, + count: null, + row, + })), + })); + return { + id: `${layer}|${kind}`, + label: kind, + count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0), + children: catNodes, + }; + }); + return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes }; + }); +} + +/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */ +export function pathIds(row: PickerRow, labels: TreeLabels): string[] { + const layer = layerOf(row); + const kind = row.종류 || labels.unclassified; + const cat = row.분류1 || labels.unclassified; + return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`]; +} + +export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice"; + +export interface FolderLine { + kind: FolderKind; + /** 보이는 이름 */ + text: string; + /** 마우스를 올렸을 때 — 키 · 번호 */ + tip: string; +} + +/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */ +export function folderLines(row: PickerRow): FolderLine[] { + return [ + { kind: "drawing", text: row.열, tip: row.도번 ?? "" }, + { kind: "area", text: row.열, tip: row.면적번 ?? "" }, + { kind: "material", text: row.열, tip: row.재료번 ?? "" }, + { kind: "combo", text: row.자재조합 ?? "", tip: row.자재조합 ?? "" }, + { kind: "unitPrice", text: row.일위대가 ?? "", tip: row.일위대가 ?? "" }, + ]; +} diff --git a/ui_template/ui_template_item_picker_fake.ts b/ui_template/ui_template_item_picker_fake.ts new file mode 100644 index 00000000..5f123c57 --- /dev/null +++ b/ui_template/ui_template_item_picker_fake.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * ui_template_item_picker_fake.ts + * 항목 목록 서버(`GET /api/m02/items` · 복사 `POST /api/m02/layers/personal/items/copy`, 21-2)가 + * 아직 없을 때만 쓰는 가짜 응답 — 21-1 모양 그대로. 실서버가 오면 이 파일과 부르는 자리를 지움. + * ========================================================================== */ + +import type { PickerRow } from "./ui_template_item_picker_data"; + +const row = ( + key: string, + 열: string, + 종류: string, + 분류1: string, + layer: PickerRow["layer"], + extra: Partial = {}, +): PickerRow => ({ + key, + 열, + 종류, + 분류1, + 분류2: "", + 분류3: "", + 단위: "m", + 일위대가: `${열} 일위대가`, + 자재조합: `${열} 자재`, + 도번: `SD${key.slice(2)}`, + 면적번: `SA${key.slice(2)}`, + 재료번: `SM${key.slice(2)}`, + layer, + owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null, + ...extra, +}); + +let personalSeq = 900; + +const memory: PickerRow[] = [ + row("SS000001", "돌기슭막이 메쌓기 1.2m 기초유", "돌기슭막이", "메쌓기", "system"), + row("SS000002", "돌기슭막이 메쌓기 1.5m 기초유", "돌기슭막이", "메쌓기", "system"), + row("SS000003", "돌기슭막이 찰쌓기 1.2m", "돌기슭막이", "찰쌓기", "system"), + row("SS000004", "콘크리트포장 T=20cm B=3m", "콘크리트포장", "일반", "system", { 단위: "㎡" }), + row("SS000005", "관보호공 콘크리트 유입", "관보호공", "콘크리트", "system", { 단위: "개소" }), + row("SS000101", "돌기슭막이 메쌓기 1.2m(회사)", "돌기슭막이", "메쌓기", "company"), + row("SS000102", "옹벽 H=2m(회사)", "옹벽", "", "company"), + row("SS000201", "돌기슭막이 메쌓기 1.2m(개인)", "돌기슭막이", "메쌓기", "personal"), +]; + +export const fakeItems = (): PickerRow[] => memory.map((item) => ({ ...item })); + +/** 복사 — 새 개인 항목을 맨 뒤에 더하고 그 줄을 돌려줌(서버는 새 SS 키를 돌려줄 것) */ +export function fakeCopy(from: PickerRow): PickerRow { + personalSeq += 1; + const copy: PickerRow = { + ...from, + key: `SS000${personalSeq}`, + layer: "personal", + owner: "샘플회사/사용자", + }; + memory.push(copy); + return { ...copy }; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 464cdccf..2a247992 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -56,4 +56,27 @@ export const ui_locales_m2 = { M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"], M02_StructureNone: ["없음", "None"], M02_Back: ["돌아가기", "Back"], + M02_PickerTitle: ["항목 고르기", "Pick an item"], + M02_PickerSearch: ["항목 찾기", "Search items"], + M02_PickerSystem: ["마스터", "Master"], + M02_PickerCompany: ["회사", "Company"], + M02_PickerPersonal: ["개인", "Personal"], + M02_PickerNoClass: ["분류 없음", "Unclassified"], + M02_PickerHint: ["왼쪽에서 항목을 고를 것", "Pick an item on the left"], + M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"], + M02_PickerLoading: ["읽는 중…", "Loading…"], + M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"], + M02_PickerDrawing: ["도면", "Drawing"], + M02_PickerArea: ["면적산출", "Area takeoff"], + M02_PickerMaterial: ["재료산출", "Material takeoff"], + M02_PickerCombo: ["자재조합", "Material combo"], + M02_PickerUnitPrice: ["일위대가", "Unit price"], + M02_PickerUnit: ["단위", "Unit"], + M02_PickerNone: ["없음", "None"], + M02_PickerApply: ["적용", "Apply"], + M02_PickerClear: ["해제", "Clear"], + M02_PickerCopy: ["개인용으로 복사", "Copy to personal"], + M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], + M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], + M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], } as const; diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css new file mode 100644 index 00000000..228ae44f --- /dev/null +++ b/ui_template/ui_template_tree.css @@ -0,0 +1,23 @@ +/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */ +.ui-tree__kids { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ui-tree__branch { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ui-tree__caret { + display: inline-block; + width: var(--spacing-12); + color: var(--color-text-muted); +} + +.ui-tree__count { + color: var(--color-text-muted); + font-size: var(--text-caption); +} diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts new file mode 100644 index 00000000..b33cbc3b --- /dev/null +++ b/ui_template/ui_template_tree.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * ui_template_tree.ts + * 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀. + * 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`). + * ========================================================================== */ + +import "./ui_template_tree.css"; +import { el } from "./ui_template_elements"; + +export interface TreeNode { + id: string; + label: string; + /** 줄 수 — null 이면 안 보임 */ + count: number | null; + /** 눌렀을 때 — 없으면 접기/펴기만 */ + run?: () => void; + children?: TreeNode[]; + /** 처음부터 펴짐 */ + open?: boolean; + /** 마우스를 올렸을 때 글(기본 줄) */ + tip?: string; +} + +/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */ +export type MakeRow = ( + node: TreeNode, + caret: string, + depth: number, + onClick: () => void, +) => HTMLElement; + +export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] { + return nodes.map((node) => { + if (!node.children?.length) return make(node, "", depth, () => node.run?.()); + const kids = el("div", { + className: "ui-tree__kids", + children: renderTree(node.children, make, depth + 1), + }); + kids.hidden = !node.open; + const row = make(node, kids.hidden ? "▸" : "▾", depth, () => { + kids.hidden = !kids.hidden; + const caret = row.querySelector(".ui-tree__caret"); + if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; + node.run?.(); + }); + return el("div", { className: "ui-tree__branch", children: [row, kids] }); + }); +} + +/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */ +export function createTreeRow( + node: TreeNode, + caret: string, + depth: number, + onClick: () => void, +): HTMLButtonElement { + const button = el("button", { + className: "ui-tree__row ui-sidebar-row", + attrs: { type: "button" }, + children: [ + el("span", { + children: [ + el("span", { className: "ui-tree__caret", text: caret }), + el("span", { text: node.label }), + ], + }), + ...(node.count === null + ? [] + : [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]), + ], + }); + button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; + if (node.tip) button.title = node.tip; + button.addEventListener("click", onClick); + return button; +} From 589223289006d74b65ba9f82244bbf52d25325e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 12:00:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(B05=C2=B7M02):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A7=91=EA=B3=84=20=ED=95=AD=EB=AA=A9=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=A0=95=20=ED=95=AD=EB=AA=A9=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20(PLAN=2021-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary_item 칸: StructureInstance · pipe_points 점 기록(options 밖) — 모양 검사 한 벌 common_util_summary_item(층 system·company·personal · owner null·{회사}·{회사}/{사용자} · key SS+6자리 · name) · 없으면 null(옛 파일 호환) · 관 요청 모양 틀림 400 - 저장 스냅샷 M02_Item_Snapshot: PUT /route/structures · PUT /drainage/pipe-points 뒤 지정 항목마다 templates/items/{layer}_{owner}_{key}/ 에 집계(SS 줄 + 열 정의) · 도면 · 면적 · 재료 · 자재조합 · 일위대가(UA 줄 + 담은 로직 줄) · manifest(원본 · 판 · 적용일 · 쓰는 구조물) — 있는 폴더는 다시 안 씀(원본이 바뀌어도 프로젝트 값 그대로) · 안 쓰는 폴더 지움 · 실패해도 저장 유지 - 채움 fill_table = 지정 항목 모드: 구조물이 고른 열(묶음)에만 · 미지정 · 복사본 없음은 안 셈 + 알림 · 스냅샷 열 정의 사용 · 회사 · 개인 항목은 열id@폴더 새 열 · 자동 맞춤은 fill_document 기본으로만 남음 - 관 시설 되붙이기가 summary_item 도 옮김 - [초기화]는 templates/ 를 안 건드림 · 복제 도구는 폴더 통째 복사 — 손댈 것 없음(시험으로 지킴) - 시험: test_structures_summary_snapshot 17 · 옛 채움 시험은 자동 맞춤 길(fill_document)로 · 층 라우트 채움 시험은 새 규칙(미지정 관 = 줄 없음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc --- .../B04_PreProcess_Router_Basins.py | 10 +- B05_Profile/B05_Profile_Structures_Router.py | 5 + B05_Profile/B05_Profile_Structures_Schema.py | 11 +- M02_MasterTemplete/M02_Item_Snapshot.py | 197 ++++++++++++++ M02_MasterTemplete/M02_Table_Fill.py | 89 ++++++- common_util/common_util_drainage_pipes.py | 7 + common_util/common_util_summary_item.py | 61 +++++ resources/tester/test_m02_table_fill.py | 12 +- resources/tester/test_m02_template_layers.py | 5 +- .../test_structures_summary_snapshot.py | 248 ++++++++++++++++++ 10 files changed, 632 insertions(+), 13 deletions(-) create mode 100644 M02_MasterTemplete/M02_Item_Snapshot.py create mode 100644 common_util/common_util_summary_item.py create mode 100644 resources/tester/test_structures_summary_snapshot.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 453e3de1..886996e7 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -269,7 +269,10 @@ async def _resolve( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) - requested = parse_pipe_points((payload or {}).get("points")) + try: + requested = parse_pipe_points((payload or {}).get("points")) + except ValueError as error: # 집계 항목(summary_item) 모양이 틀림 + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) signature = route_signature(context.vertices) stored = ( load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None @@ -349,6 +352,11 @@ async def put_pipe_points( _basin_features(context, detail, points), context.crs, ) + # 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지) + from common_util.common_util_storage import resolve_stored_project_path + from M02_MasterTemplete.M02_Item_Snapshot import safe_sync + + await asyncio.to_thread(safe_sync, resolve_stored_project_path(context.stored_path)) result = _payload(project_id, context, detail, points, saved=True) result["saved_count"] = saved return result diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index f5396e6e..5e0f3c40 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -8,6 +8,7 @@ 쪽이 앞의 편집을 조용히 지우지 않게. """ +import asyncio import logging from pathlib import Path from typing import Any @@ -282,6 +283,10 @@ async def write_structures( base_revision=payload.base_revision, max_chainage_m=await _route_length(project_id), ) + # 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지) + from M02_MasterTemplete.M02_Item_Snapshot import safe_sync + + await asyncio.to_thread(safe_sync, root) # 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도 # 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE # 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다). diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 7e851bba..42ea75e9 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -13,7 +13,9 @@ import os from functools import lru_cache from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from common_util.common_util_summary_item import parse_summary_item # 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). STRUCTURE_PLACEMENTS = ("point", "interval", "site") @@ -156,6 +158,13 @@ class StructureInstance(BaseModel): revision: int = Field(default=0, ge=0) # 부지형 polygon 예약 — 1차 UI는 측점+면적 속성만 쓴다. geometry: dict[str, Any] | None = None + # 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷 + summary_item: dict[str, Any] | None = None + + @field_validator("summary_item") + @classmethod + def validate_summary_item(cls, value: Any) -> dict[str, Any] | None: + return parse_summary_item(value) @model_validator(mode="after") def validate_placement_fields(self) -> "StructureInstance": diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py new file mode 100644 index 00000000..1c35e542 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -0,0 +1,197 @@ +"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3). + +[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: + - 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사 + (`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM · + `자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`) + - 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트 + 값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로 + - 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) +원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file +from common_util.common_util_json import atomic_write_json +from common_util.common_util_summary_item import snapshot_folder_name +from M02_MasterTemplete import M02_Template_Layers as layers + +logger = logging.getLogger(__name__) + +ITEMS_DIRNAME = "items" +MANIFEST_NAME = "manifest.json" +TABLE_NAME = "구조물집계표" +UM_FILE = "자재조합.json" +UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`) +#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) +PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) + + +def items_dir(project_root: str | Path) -> Path: + return layers.project_dir(project_root) / ITEMS_DIRNAME + + +def layer_root(item: dict[str, Any]) -> Path: + """항목 원본 층 뿌리 — 시스템 · 회사 · 개인(21-2 층 폴더 함수가 오면 그리로).""" + if item["layer"] == "system": + return layers.system_dir() + if item["layer"] == "company": + return layers.company_dir(item["owner"]) + company, user = str(item["owner"]).split("/") + return layers.personal_dir(company, user) + + +def _read(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _row(document: Any, key: str) -> dict[str, Any] | None: + rows = document.get("줄") if isinstance(document, dict) else None + for row in rows or []: + if isinstance(row, dict) and row.get("키") == key: + return row + return None + + +def _labor_rows(ua_key: str) -> dict[str, Any] | None: + """UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`).""" + from M01_MasterData import M01_MasterData_Store as m01 + + ua = _row(_read(Path(m01.FOLDER) / UA_FILE), ua_key) + if ua is None: + return None + wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")] + found: dict[str, dict[str, Any]] = {} + raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음 + for key in wanted: + try: + file = m01.logic(key)["file"] + except m01.StoreError: + continue + if file not in raw: + raw[file] = _read(Path(m01.FOLDER) / file) + row = _row(raw[file], key) + if row is not None: + found[key] = row + return { + "줄": ua, + "로직": [found[key] for key in wanted if key in found], + "빠진로직": [key for key in wanted if key not in found], + } + + +def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: + """원본 층에서 항목 내용을 모음 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" + root = layer_root(item) + row = _row(_read(root / layers.COMBO_FILE), item["key"]) + if row is None: + raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음") + column_id = str(row.get("열") or "") + table = _read(root / "table" / f"{TABLE_NAME}.json") or {} + column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None) + files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}} + for kind, name in PART_FILES: + part = _read(root / kind / f"{column_id}.json") if column_id else None + if part is not None: + files[name] = part + if row.get("자재조합"): + um = _row(_read(root / UM_FILE), str(row["자재조합"])) + if um is not None: + files["자재조합.json"] = um + if row.get("일위대가"): + labor = _labor_rows(str(row["일위대가"])) + if labor is not None: + files["일위대가.json"] = labor + return files + + +def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]: + """`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`).""" + found: dict[str, tuple[dict[str, Any], list[str]]] = {} + + def add(item: dict[str, Any] | None, owner_id: str) -> None: + if item: + found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id) + + _, structures = load_structures(str(project_root)) + for index, structure in enumerate(structures): + add(structure.summary_item, structure.structure_id or f"#{index}") + for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): + add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}") + return found + + +def _version(files: dict[str, Any]) -> str: + raw = json.dumps(files, ensure_ascii=False, sort_keys=True).encode("utf-8") + return hashlib.sha256(raw).hexdigest()[:16] + + +def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: + """지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름.""" + base = items_dir(project_root) + wanted = assignments(project_root) + made: list[str] = [] + failed: list[str] = [] + for folder, (item, users) in sorted(wanted.items()): + target = base / folder + manifest = _read(target / MANIFEST_NAME) + if isinstance(manifest, dict): + if manifest.get("구조물") != users: + atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users}) + continue + try: + files = build_snapshot(item) + except (LookupError, ValueError) as error: + logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error) + failed.append(folder) + continue + target.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + atomic_write_json(target / name, content) + # manifest 는 마지막 — 있으면 다 쓴 폴더(도중에 끊기면 다음 저장이 다시 씀) + atomic_write_json( + target / MANIFEST_NAME, + { + "원본": item, + "판": _version(files), + "적용일": datetime.now().isoformat(timespec="seconds"), + "구조물": users, + }, + ) + made.append(folder) + removed: list[str] = [] + if base.is_dir(): + for child in sorted(base.iterdir()): + if child.is_dir() and child.name not in wanted: + shutil.rmtree(child) + removed.append(child.name) + return {"만듦": made, "지움": removed, "못만듦": failed} + + +def safe_sync(project_root: str | Path) -> None: + """저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤).""" + try: + result = sync_snapshots(project_root) + if any(result.values()): + logger.info("집계 항목 스냅샷: %s", result) + except Exception: + logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root) + + +def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None: + """프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None.""" + data = _read(items_dir(project_root) / snapshot_folder_name(item) / "집계.json") + return data if isinstance(data, dict) else None diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index d0107c4d..812c84d1 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -106,6 +106,7 @@ def collect_items( "start_m": item.start_m, "end_m": item.end_m, "options": _with_defaults(definition, dict(item.options or {})), + "summary_item": item.summary_item, } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): @@ -120,6 +121,7 @@ def collect_items( "end_m": None, "options": _with_defaults(types.get(type_id), dict(point.options or {})), "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, + "summary_item": point.summary_item, } ) return items @@ -186,6 +188,27 @@ def _sources(binding: dict[str, Any]) -> list[dict[str, Any]]: return [main, *(binding.get("더함") or [])] +def _home(column: dict[str, Any]) -> str: + """펼친 열이면 마스터 열의 묶음 — 지정 항목이 고른 열(묶음)과 견줌.""" + return _group(_frame(column).get("원열") or column) + + +def _assigned_match(source: dict[str, Any], item: dict[str, Any], column: dict[str, Any]) -> bool: + """지정 항목 모드 — 구조물이 고른 열(묶음)에만 · 종류 · 조건 · 옵션으로 자동 맞추지 않음. + + 값을 읽을 출처 = 바인딩 출처 가운데 그 구조물 종류 것(없으면 주 출처). + """ + if item.get("열묶음") != _home(column): + return False + sources = _sources(_frame(column)) + typed = [entry for entry in sources if entry.get("종류") == item["type_id"]] + return source == (typed[0] if typed else sources[0]) + + +def _auto_match(source: dict[str, Any], item: dict[str, Any], _column: dict[str, Any]) -> bool: + return _matches(source, item) + + def _expansion(source: dict[str, Any], item: dict[str, Any]) -> tuple[str, ...]: values = [] for key in source.get("펼침") or []: @@ -313,6 +336,7 @@ def _expand_columns( items: list[dict[str, Any]], variables: dict[str, Any], choices: dict[str, list[str]], + match: Any = _auto_match, ) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]: """펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …). @@ -333,7 +357,7 @@ def _expand_columns( found = groups.setdefault(_group(column), set()) for source in _sources(binding): # 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함 - found.update(_expansion(source, item) for item in items if _matches(source, item)) + found.update(_expansion(source, item) for item in items if match(source, item, column)) out: list[dict[str, Any]] = [] made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {} @@ -394,14 +418,19 @@ def fill_document( document: dict[str, Any], items: list[dict[str, Any]], types: dict[str, StructureType] | None = None, + assigned: bool = False, ) -> dict[str, Any]: - """표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본).""" + """표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본). + + `assigned` = 지정 항목 모드(`fill_table`) — 구조물마다 고른 열(`열묶음`)에만 넣음. + """ + match = _assigned_match if assigned else _auto_match types = types or structure_type_map() doc = copy.deepcopy(document) variables = dict(doc.get("변수") or {}) interval = _number(variables.get("측점간격_m")) or 20.0 rule_columns = _unexpand(doc.get("열") or []) - columns, made = _expand_columns(rule_columns, items, variables, _choices(types)) + columns, made = _expand_columns(rule_columns, items, variables, _choices(types), match) notes: list[str] = [] # 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔) @@ -427,7 +456,7 @@ def fill_document( if source.get("값") in (None, "식"): continue for item, slot in zip(items, item_slot, strict=True): - if not _matches(source, item): + if not match(source, item, column): continue if expanded is not None and list(_expansion(source, item)) != expanded: continue @@ -558,14 +587,62 @@ def is_fillable(document: Any) -> bool: ) +def assign_items( + project_root: str | Path, document: dict[str, Any], items: list[dict[str, Any]] +) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]: + """지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3). + + - 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩 + - 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값) + - 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게) + 돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림). + """ + from common_util.common_util_summary_item import snapshot_folder_name + from M02_MasterTemplete.M02_Item_Snapshot import read_snapshot + + doc = copy.deepcopy(document) + rules = _unexpand(doc.get("열") or []) + out: list[dict[str, Any]] = [] + unassigned = missing = 0 + for item in items: + chosen = item.get("summary_item") + column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None + if not chosen or not isinstance(column, dict) or not column.get("id"): + unassigned += not chosen + missing += bool(chosen) + continue + new = copy.deepcopy(column) + if chosen["layer"] != "system": + new["id"] = f"{column['id']}@{snapshot_folder_name(chosen)}" + if isinstance(new.get("바인딩"), dict) and new["바인딩"].get("묶음"): + new["바인딩"]["묶음"] = new["id"] + at = next((i for i, c in enumerate(rules) if c.get("id") == new["id"]), None) + if at is None: + rules.append(new) + else: + rules[at] = new + out.append({**item, "열": new["id"], "열묶음": _group(new)}) + doc["열"] = rules + notes = [] + if unassigned: + notes.append(f"집계 항목 미지정 {unassigned}건 — 표에 안 들어감") + if missing: + notes.append(f"집계 항목 복사본 없음 {missing}건 — [저장] 뒤 채워짐") + return doc, out, notes + + def fill_table( project_root: str | Path, document: dict[str, Any], pipe_lengths: dict[float, float] | None = None, ) -> dict[str, Any]: - """프로젝트 설계로 표 문서를 채움 — 저장 안 함.""" + """프로젝트 설계로 표 문서를 채움 — 저장 안 함 · 지정 항목 모드(미지정은 빈칸).""" types = structure_type_map() - return fill_document(document, collect_items(project_root, pipe_lengths, types), types) + items = collect_items(project_root, pipe_lengths, types) + doc, chosen, notes = assign_items(project_root, document, items) + filled = fill_document(doc, chosen, types, assigned=True) + filled["알림"] = [*notes, *filled.get("알림", [])] + return filled def recalc(document: dict[str, Any]) -> dict[str, Any] | None: diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index a9aecced..0dcec025 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -35,6 +35,7 @@ from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from common_util.common_util_summary_item import parse_summary_item from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME, @@ -89,6 +90,8 @@ class PipePoint: # 관이 실제로 놓인 자리(사업지 CRS, m). 노선이 바뀌어도 이 자리는 그대로다. x: float | None = None y: float | None = None + # 구조물 집계표 항목(PLAN 21-1 · options 밖) — 없으면 None · 모양은 `common_util_summary_item` + summary_item: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. @@ -110,6 +113,8 @@ class PipePoint: if self.x is not None and self.y is not None: payload["x"] = round(float(self.x), 3) payload["y"] = round(float(self.y), 3) + if self.summary_item: + payload["summary_item"] = self.summary_item return payload @@ -349,6 +354,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: if isinstance(options, dict) and options else None ), + summary_item=parse_summary_item(item.get("summary_item")), ) ) points.sort(key=lambda point: point.chainage_m) @@ -399,6 +405,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) point.start_m = ref.start_m point.end_m = ref.end_m point.options = ref.options + point.summary_item = ref.summary_item return base diff --git a/common_util/common_util_summary_item.py b/common_util/common_util_summary_item.py new file mode 100644 index 00000000..08f58c67 --- /dev/null +++ b/common_util/common_util_summary_item.py @@ -0,0 +1,61 @@ +"""구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1). + + summary_item = {layer: "system"|"company"|"personal", + owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"} + +구조물 기록(`structures.json` `StructureInstance`) · 관 지점 기록(`pipe_points.json` 점) 두 곳이 +같은 칸을 씀 — 검사는 여기 한 벌. 없으면 null(옛 파일 호환). +""" + +from __future__ import annotations + +import re +from typing import Any + +SUMMARY_LAYERS = ("system", "company", "personal") +_KEY = re.compile(r"^SS\d{6}$") +_OWNER = { + "system": None, + "company": re.compile(r"^\d+$"), + "personal": re.compile(r"^\d+/\d+$"), +} +_NAME_MAX = 200 + + +def parse_summary_item(value: Any) -> dict[str, Any] | None: + """모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError.""" + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("집계 항목(summary_item)은 객체여야 합니다.") + extra = set(value) - {"layer", "owner", "key", "name"} + if extra: + raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}") + layer = value.get("layer") + if layer not in SUMMARY_LAYERS: + raise ValueError("집계 항목 층은 system · company · personal 중 하나입니다.") + owner = value.get("owner") + rule = _OWNER[layer] + if rule is None: + if owner is not None: + raise ValueError("시스템 층 집계 항목의 owner 는 null 이어야 합니다.") + elif not isinstance(owner, str) or not rule.match(owner): + shape = "{회사}" if layer == "company" else "{회사}/{사용자}" + raise ValueError(f"{layer} 층 집계 항목의 owner 는 「{shape}」 숫자 모양입니다.") + key = value.get("key") + if not isinstance(key, str) or not _KEY.match(key): + raise ValueError("집계 항목 key 는 SS 와 숫자 6자리입니다(예: SS000004).") + name = value.get("name") + if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX: + raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.") + return {"layer": layer, "owner": owner, "key": key, "name": name} + + +def snapshot_folder_name(item: dict[str, Any]) -> str: + """`{layer}_{owner 슬러그}_{key}` — owner 없음 = `none` · `/` = `-`. + + 예: `system_none_SS000004` · `company_3_SS000001` · `personal_3-12_SS000002`. + """ + owner = item.get("owner") + slug = str(owner).replace("/", "-") if owner else "none" + return f"{item['layer']}_{slug}_{item['key']}" diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index ab0dae5b..ed7b11cb 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -71,8 +71,14 @@ def project(tmp_path: Path) -> Path: return root +def _auto(project: Path, document: dict[str, Any], lengths: dict[float, float]) -> dict[str, Any]: + """바인딩 자동 맞춤(`fill_document` 기본) — 프로젝트 `fill_table` 은 지정 항목 모드(PLAN 21-3)라 + 펼침 · 묶음 · 손 값 규칙은 이 길로 봄.""" + return fill.fill_document(document, fill.collect_items(project, lengths)) + + def _filled(project: Path, document: dict[str, Any] | None = None) -> dict[str, Any]: - return fill.fill_table(project, document or _master(), {85.0: 14.0}) + return _auto(project, document or _master(), {85.0: 14.0}) def _col(doc: dict[str, Any], col_id: str) -> dict[str, Any]: @@ -119,7 +125,7 @@ def test_계산_열은_설계값_딱지_없음(project: Path) -> None: assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0] assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"] # 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림) - again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0}) + again = _auto(project, fill.strip_design(doc), {85.0: 14.0}) assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]] @@ -152,7 +158,7 @@ def test_측점_줄_같은_측점은_한_줄_관은_나눔(project: Path) -> Non assert [row["값"].get("no") for row in doc["줄"][1:]] == [str(i) for i in range(1, 8)] assert "식" not in rows["s200.00~230.00"] # 줄눈 간격이 달라도 열 식이 없으면 줄 식 없음 assert doc["알림"] == [] - bare = fill.fill_table(project, _master(), {}) + bare = _auto(project, _master(), {}) assert bare["알림"] == ["관 연장 없음 2곳 — B06 횡단 설계 전이면 빈칸"] assert not any("pp_len" in key for row in bare["줄"] for key in row["값"]) diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index c9723a67..e533ac28 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -207,8 +207,9 @@ def test_채운_표는_작업본을_채워_돌려주고_저장_안_함( ) before = layers.version_of(root / "templates/table/구조물집계표.json") got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json() - row = next(r for r in got["문서"]["줄"] if r["id"] == "s40.00") - assert row["값"]["sta"] == "NO.2" and row["값"]["pp_len|파형강관|1000"] == 12 + # 프로젝트 채움 = 지정 항목 모드(PLAN 21-3) — 집계 항목 미지정 관은 표에 안 들어감 + assert not any(r["id"] == "s40.00" for r in got["문서"]["줄"]) + assert "집계 항목 미지정 1건 — 표에 안 들어감" in got["문서"]["알림"] assert got["결과"] == {"계산": {}} and got["판"] == before assert layers.version_of(root / "templates/table/구조물집계표.json") == before diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py new file mode 100644 index 00000000..73773801 --- /dev/null +++ b/resources/tester/test_structures_summary_snapshot.py @@ -0,0 +1,248 @@ +"""구조물 집계표 항목 지정 · 저장 스냅샷 · 채움 (PLAN 21-3 · 임시 저장소). + +- `summary_item` 칸(구조물 · 관 지점) 모양 검사 · 옛 파일(칸 없음) 호환 +- 저장 뒤 스냅샷: 지정 → 폴더 생김 · 원본(마스터) 바꿔도 프로젝트 값 그대로 · 지정 바꿈 → 옛 폴더 + 지움 · 해제 → 지움 · 회사 층 자리 +- 채움(`fill_table`) = 지정 항목 모드 — 고른 열에만 · 미지정은 안 셈 · 스냅샷 열 정의 사용 +- [초기화]는 `templates/` 를 안 건드림 · 프로젝트 복제 도구는 폴더를 통째 복사 +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from common_util import common_util_initial_snapshot as initial +from common_util.common_util_drainage_pipes import PipePoint, parse_pipe_points +from common_util.common_util_summary_item import parse_summary_item +from config import config_system +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete import M02_Template_Layers as layers + +REAL = config_system.PROJECT_ROOT / "resources/master_template" +MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} +RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"} +PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} + + +def _copy_layer(target: Path) -> None: + target.mkdir(parents=True) + for name in (layers.COMBO_FILE, "자재조합.json"): + shutil.copy2(REAL / name, target / name) + for kind in ("table", "structure", "area", "material"): + shutil.copytree(REAL / kind, target / kind) + + +@pytest.fixture +def world(tmp_path: Path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "master" + _copy_layer(system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + root = storage / "3" / "12" / "proj" + (root / "B05_Profile/route").mkdir(parents=True) + (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) + return {"system": system, "storage": storage, "root": root} + + +def _save(root: Path, structures: list[dict], points: list[dict]) -> dict[str, list[str]]: + """화면 [저장] 두 곳(구조물 · 관 지점)이 쓴 뒤 서버가 맞추는 것과 같게.""" + for index, item in enumerate(structures): + item.setdefault("structure_id", f"st{index}") + item.setdefault("placement", "interval" if "start_m" in item else "point") + (root / "B05_Profile/route/structures.json").write_text( + json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8" + ) + (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + return snap.sync_snapshots(root) + + +def _dry(summary: dict | None) -> dict[str, Any]: + wall = {"type_id": "masonry_dry", "chainage_m": 85, "start_m": 80, "end_m": 90} + return {**wall, "options": {"height_m": 2.0, "length_m": 10}, "summary_item": summary} + + +def _wet() -> dict[str, Any]: + wall = {"type_id": "masonry_wet", "chainage_m": 120, "start_m": 120, "end_m": 130} + return {**wall, "options": {"height_m": 2.5, "length_m": 10}} + + +def _pipe(summary: dict | None) -> dict[str, Any]: + options = {"pipe_kind": "파형강관", "pipe_diameter_mm": "1000"} + return {"chainage_m": 40.0, "source": "user", "options": options, "summary_item": summary} + + +def _items(root: Path) -> Path: + return root / "templates" / "items" + + +# ── 칸 모양 ────────────────────────────────────────── + + +@pytest.mark.parametrize( + "bad", + [ + {"layer": "project", "owner": None, "key": "SS000001", "name": "x"}, + {"layer": "system", "owner": "3", "key": "SS000001", "name": "x"}, + {"layer": "company", "owner": "3/12", "key": "SS000001", "name": "x"}, + {"layer": "personal", "owner": "3", "key": "SS000001", "name": "x"}, + {"layer": "system", "owner": None, "key": "UA000001", "name": "x"}, + {"layer": "system", "owner": None, "key": "SS000001", "name": ""}, + {"layer": "system", "owner": None, "key": "SS000001", "name": "x", "more": 1}, + "SS000001", + ], +) +def test_모양이_틀린_항목은_거절(bad): + with pytest.raises(ValueError): + parse_summary_item(bad) + with pytest.raises(ValueError): + StructureInstance(type_id="x", placement="point", chainage_m=1, summary_item=bad) + with pytest.raises(ValueError): + parse_pipe_points([{"chainage_m": 1.0, "summary_item": bad}]) + + +def test_옛_파일은_칸_없음_그대로_읽고_씀(): + item = StructureInstance(type_id="x", placement="point", chainage_m=1) + assert item.summary_item is None + point = parse_pipe_points([{"chainage_m": 10.0, "source": "user"}])[0] + assert point.summary_item is None and "summary_item" not in point.as_dict() + owned = {"layer": "personal", "owner": "3/12", "key": "SS000002", "name": "내 옹벽"} + again = parse_pipe_points([PipePoint(chainage_m=5.0, summary_item=owned).as_dict()])[0] + assert again.summary_item == owned + + +# ── 스냅샷 ─────────────────────────────────────────── + + +def test_지정하면_폴더가_생기고_내용이_원본과_같음(world): + root = world["root"] + result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)]) + assert result == { + "만듦": ["system_none_SS000011", "system_none_SS000022"], + "지움": [], + "못만듦": [], + } + folder = _items(root) / "system_none_SS000022" + got = {path.name for path in folder.iterdir()} + assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got + total = json.loads((folder / "집계.json").read_text(encoding="utf-8")) + assert total["줄"]["키"] == "SS000022" and total["열"]["id"] == "ms" + drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8")) + assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing + manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8")) + assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16 + pipe = json.loads( + (_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8") + ) + assert pipe["구조물"] == ["pipe@40.0"] + # 일위대가 UA 줄 + 담은 로직 줄(M01 시스템) + labor = json.loads( + (_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8") + ) + assert labor["줄"]["키"] == "UA000036" + assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [ + entry["로직"] for entry in labor["줄"]["담은로직"] + ] + + +def test_원본을_바꿔도_프로젝트_값은_그대로(world): + root, system = world["root"], world["system"] + _save(root, [_dry(MS)], []) + before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") + table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8")) + for column in table["열"]: + if column["id"] == "ms": + column["바인딩"]["머리틀"] = ["바뀐 돌쌓기", "{0}", "{1}"] + (system / "table/구조물집계표.json").write_text( + json.dumps(table, ensure_ascii=False), encoding="utf-8" + ) + assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀 + after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") + assert after == before + doc = fill.fill_table(root, table) + heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")] + assert heads == ["돌쌓기"] # 채움도 스냅샷 열 정의 + + +def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world): + root = world["root"] + _save(root, [_dry(MS), _dry(MS)], []) + manifest = _items(root) / "system_none_SS000022/manifest.json" + assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"] + # 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦 + result = _save(root, [_dry(RV), _dry(MS)], []) + assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == [] + assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"] + # 해제 · 삭제 + assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"] + assert list(_items(root).iterdir()) == [] + + +def test_회사_층_항목은_회사_폴더에서_복사(world): + root = world["root"] + company = world["storage"] / "3" / "templates" + _copy_layer(company) + combo = json.loads((company / layers.COMBO_FILE).read_text(encoding="utf-8")) + for row in combo["줄"]: + if row["키"] == "SS000022": + row["분류3"] = "회사 것" + (company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8") + owned = {**MS, "layer": "company", "owner": "3"} + assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"] + total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8")) + assert total["줄"]["분류3"] == "회사 것" + # 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열 + doc = fill.fill_table( + root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) + ) + ids = [c["id"] for c in doc["열"] if "@" in c["id"]] + assert ids == ["ms@company_3_SS000022|메쌓기|2.0"] + + +def test_원본에_없는_항목은_못만듦으로_남김(world): + gone = {**MS, "key": "SS999999"} + assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"] + + +# ── 채움 ───────────────────────────────────────────── + + +def test_채움은_지정한_열에만_미지정은_안_셈(world): + root = world["root"] + _save(root, [_dry(MS), _wet()], [_pipe(PIPE), {**_pipe(None), "chainage_m": 60.0}]) + table = json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) + doc = fill.fill_table(root, table, {40.0: 12.0, 60.0: 9.0}) + values = {row["id"]: row["값"] for row in doc["줄"] if not row.get("고정")} + assert set(values) == {"s80.00~90.00", "s40.00"} # 미지정 옹벽 · 관은 줄도 없음 + assert values["s80.00~90.00"]["ms|메쌓기|2.0"] == 10 # 메쌓기는 더함 출처(종류)로 + assert values["s40.00"]["pp_len|파형강관|1000"] == 12 + assert not any("찰쌓기" in c["id"] for c in doc["열"]) # 자동 맞춤 없음 + assert "집계 항목 미지정 2건 — 표에 안 들어감" in doc["알림"] + + +def test_스냅샷이_없으면_알림(world): + root = world["root"] + _save(root, [_dry(MS)], []) + shutil.rmtree(_items(root)) + doc = fill.fill_table( + root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) + ) + assert "집계 항목 복사본 없음 1건 — [저장] 뒤 채워짐" in doc["알림"] + + +# ── 초기화 · 복제 ──────────────────────────────────── + + +def test_초기화는_templates_를_안_건드리고_복제는_통째로(): + assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) + tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") + assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)