From 057d3976f943869b65a29097b1656b14ca54033a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:17:33 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(M02):=20=ED=95=AD=EB=AA=A9=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=9E=85=EB=A0=A5=20=EC=B8=B5=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=ED=8B=80=20=E2=80=94=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=C2=B7=20=EC=95=8C=EC=95=BD(PLAN=2025-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/m02/layers/{layer}/items/{key}/inputs — 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값(master_auto) · 빠진로직 · 일위대가 없으면 빈 목록 · 층 읽기 권한(시스템 층 = 로그인 누구나) - GET|PUT …/items/{key}/input-links — 층 폴더 inputs/<열id>.json {판, 줄: [{로직, 입력, 자리, 알약?}]} · 없으면 {판: null, 줄: []} · 판 다르면 409 · 없는 로직 · 입력 · 모르는 자리 · 두 번은 422 · 알약 키 입력|로직|입력 은 서버가 붙임 · 일위대가가 바뀌어 사라진 연결은 읽을 때 사라짐 표시 - GET …/items/{key}/pills?place=면적|재료 — 조합 물량 알약(면적만) + 그 시트로 이은 입력 알약(이름 = 입력 · 로직 이름) - 열 지우기 · 분류 더하기 복사 · 항목 개인 복사가 연결 파일을 같이 다룸(판 1 부터) · 스냅샷용 읽기 함수 read_file · copy_between - M01 은 읽기만(Store · Store_Combo · master_auto) - 시험 test_m02_item_inputs 4건(실제 마스터 항목 셋 로직 · 입력 · 자동값 = M01 · 연결 저장 · 409 · 422 · 사라짐 · 알약 · 권한 · 복사 · 지우기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- .../M02_MasterTemplete_Inputs.py | 229 +++++++++++++++++ .../M02_MasterTemplete_Items.py | 4 +- .../M02_MasterTemplete_Router_Items.py | 50 ++++ .../M02_MasterTemplete_Store.py | 26 +- resources/tester/test_m02_item_inputs.py | 238 ++++++++++++++++++ 5 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs.py create mode 100644 resources/tester/test_m02_item_inputs.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py new file mode 100644 index 00000000..92692c6f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -0,0 +1,229 @@ +"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약. + +항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부. +로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직 +줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와 +같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함). + +항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` — +판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약 +(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀. +M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +PLACES = ("면적", "재료", "선택", None) +PILL_HEAD = "입력" +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def pill_key(logic: str, name: str) -> str: + return f"{PILL_HEAD}|{logic}|{name}" + + +def _item(key: str) -> dict[str, Any]: + """지금 층(`store.at`)의 항목 줄 — 없으면 404.""" + row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None) + if row is None: + raise store.StoreError(404, f"없는 항목 「{key}」") + return row + + +def _combo_row(ua: str) -> dict[str, Any] | None: + rows = m01.read(combo_store.FILE)[0].get("줄") or [] + return next((r for r in rows if str(r.get("키")) == ua), None) + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = _combo_row(str(ua)) + if row is None: + return None, [], [] + whole = m01.loaded()[1] + found, missing = [], [] + for one in combo_store.mcb.items(row): + code = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return row, found, missing + + +def inputs(key: str) -> dict[str, Any]: + """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. + 일위대가 없는 항목은 로직 빈 목록.""" + ua = _item(key).get("일위대가") + row, logics, missing = combo_logics(ua) + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": out, + "빠진로직": missing, + } + ) + + +# ── 항목 연결 파일 ───────────────────────────────────── + + +def read_file(column: str) -> dict[str, Any] | None: + """지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀).""" + path = store.inputs_path(column) + return json.loads(path.read_bytes()) if path.is_file() else None + + +def write_file(column: str, doc: dict[str, Any]) -> None: + """지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음).""" + path = store.inputs_path(column) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, doc) + + +def _known(key: str) -> dict[str, set[str]]: + """{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것.""" + _, logics, _ = combo_logics(_item(key).get("일위대가")) + return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics} + + +def read_links(key: str) -> dict[str, Any]: + """항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은 + `사라짐: true`.""" + doc = read_file(_item(key)["열"]) or {"판": None, "줄": []} + known = _known(key) + rows = [] + for row in doc.get("줄") or []: + gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set()) + rows.append({**row, "사라짐": True} if gone else row) + return {"판": doc.get("판"), "줄": rows} + + +def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]: + """없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임.""" + if not isinstance(rows, list): + raise store.StoreError(422, "「줄」 이 목록 아님") + known = _known(key) + out, seen, errors = [], set(), [] + for at, row in enumerate(rows, start=1): + if not isinstance(row, dict): + errors.append(f"줄{at} · 묶음 아님") + continue + logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리") + if logic not in known: + errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」") + elif name not in known[logic]: + errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」") + elif place not in PLACES: + errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null") + elif (logic, name) in seen: + errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」") + else: + seen.add((logic, name)) + one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place} + if place in ("면적", "재료"): + one["알약"] = pill_key(logic, name) + out.append(one) + if errors: + raise store.StoreError(422, {"errors": errors}) + return out + + +def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]: + """항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1.""" + column = _item(key)["열"] + clean = _clean_rows(key, rows) + with store._LOCK: + have = (read_file(column) or {}).get("판") + if version != have: + raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have}) + doc = {"판": int(have or 0) + 1, "줄": clean} + write_file(column, doc) + return doc + + +# ── 알약 — 면적산출 · 재료산출 시트 ─────────────────────── + + +def pills(key: str, place: str) -> dict[str, Any]: + """그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를 + 그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` — + 이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침).""" + if place not in ("면적", "재료"): + raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료") + ua = _item(key).get("일위대가") + quantity: list[dict[str, Any]] = [] + if place == "면적" and ua and _combo_row(str(ua)): + quantity = combo_store.combo_inputs(str(ua))["물량"] + _, logics, _ = combo_logics(ua) + by_key = {str(lg.get("키")): lg for lg in logics} + linked = [] + for row in read_links(key)["줄"]: + if row.get("자리") != place or row.get("사라짐"): + continue + logic = by_key[str(row["로직"])] + spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"]) + short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"] + linked.append( + { + "키": pill_key(row["로직"], row["입력"]), + "이름": f"{row['입력']} · {short}", + "단위": spec.get("단위"), + "로직": row["로직"], + "입력": row["입력"], + } + ) + return _num({"물량": quantity, "입력": linked}) + + +def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool: + """다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함.""" + with store.at(source): + doc = read_file(source_column) + if doc is None: + return False + with store.at(target): + write_file(target_column, {"판": 1, "줄": doc.get("줄") or []}) + return True diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index ea87928a..f535a722 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -15,6 +15,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -104,7 +105,7 @@ def _fresh_id(want: str, used: set[str]) -> str: def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]: """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · - 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 자재 조합 줄(새 UM) 복사 · + 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 · 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`.""" with store.at(source): @@ -153,4 +154,5 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d with store._LOCK: atomic_write_json(store._path(kind, new_id), body) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) + inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 9cfbe6c8..08f8ad31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_MasterTemplete_Branch as branch +from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -38,6 +39,11 @@ Session = dict[str, Any] NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" +class InputLinksBody(BaseModel): + 판: int | None = None + 줄: list[Any] = [] + + class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None @@ -145,6 +151,50 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> raise HTTPException(status_code=e.status, detail=e.detail) from e +# ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ── + + +@router.get("/layers/{layer}/items/{key}/inputs") +def get_item_inputs( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라 + 일반 사용자는 이 길로 읽음.""" + return _call(_root(session, layer, owner), inputs_store.inputs, key) + + +@router.get("/layers/{layer}/items/{key}/input-links") +def get_input_links( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + return _call(_root(session, layer, owner), inputs_store.read_links, key) + + +@router.put("/layers/{layer}/items/{key}/input-links") +def put_input_links( + layer: Layer, + key: str, + body: InputLinksBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, inputs_store.write_links, key, body.판, body.줄) + + +@router.get("/layers/{layer}/items/{key}/pills") +def get_item_pills( + layer: Layer, + key: str, + place: Literal["면적", "재료"], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`.""" + return _call(_root(session, layer, owner), inputs_store.pills, key, place) + + # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index f068b29a..4aa8d3f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -101,6 +101,16 @@ def _path(kind: str, name: str) -> Path: return root() / kind / f"{name}.json" +# 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는 +# `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이 +INPUTS = "inputs" + + +def inputs_path(column: str) -> Path: + """그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음.""" + return _path("structure", column).parent.parent / INPUTS / f"{column}.json" + + def _info(kind: str, path: Path) -> dict[str, Any]: raw = path.read_bytes() stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") @@ -163,6 +173,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) + _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸 @@ -375,13 +386,26 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: return _info(kind, _path(kind, column)) +def _copy_inputs(source: str, column: str) -> None: + """분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로).""" + have, got = inputs_path(column), inputs_path(source) + if have.is_file() or not got.is_file(): + return + have.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []}) + + def prune_removed_structures() -> list[str]: - """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 — + """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김).""" keep = set(_column_ids()) gone: list[str] = [] codes: set[str] = set() with _LOCK: + for path in sorted((root() / INPUTS).glob("[!.]*.json")): + if path.stem not in keep: + path.unlink() + gone.append(f"{INPUTS}/{path.stem}") for kind, key, _, _ in PARTS: for path in sorted((root() / kind).glob("[!.]*.json")): if path.stem not in keep: diff --git a/resources/tester/test_m02_item_inputs.py b/resources/tester/test_m02_item_inputs.py new file mode 100644 index 00000000..a72a8557 --- /dev/null +++ b/resources/tester/test_m02_item_inputs.py @@ -0,0 +1,238 @@ +"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한. + +① 입력 목록은 실제 마스터(시스템 층 · 읽기만)로 — 일위대가 있는 항목 셋에서 로직 수 · 입력 수 · + 자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄 `입력` · `master_auto`)과 같은지 · 로직마다 갈라 + 합치지 않는지 · 일위대가 없는 항목은 빈 목록. +② 연결 파일 쓰기는 임시 storage(회사 · 개인 층) + 견본 시스템 집계표로만 — 판 409 · 없는 키 422 · + 사라짐 표시 · 알약 · 층 권한 · 열 지우기 · 항목 복사 · 분류 더하기 복사가 파일을 같이 다룸. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TABLE = "table/구조물집계표" +UA = "UA000004" # 견본 항목 셋(rv · c4 · c1)이 쓰는 조합 — 담은 로직 여럿 +OTHER_UA = "UA000007" + + +def _app(session: dict[str, Any]) -> TestClient: + app = FastAPI() + app.include_router(layers_router.router) + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +def _m01_view(ua: str) -> list[tuple[str, list[str], dict]]: + """M01 조합 미리 보기가 그리는 것 — 담은 로직마다 (키, 입력 이름들, 자동값).""" + row = next(r for r in m01.read(combo_store.FILE)[0]["줄"] if r["키"] == ua) + whole = m01.loaded()[1] + out = [] + for one in row["담은로직"]: + try: + logic = whole.logic(one["로직"]) + except combo_store.cm.mf.FormulaError: + continue # 화면도 못 읽은 로직은 건너뜀 + auto = inputs_store._num(m01.auto(one["로직"])["값"]) + out.append((one["로직"], [s["이름"] for s in logic.get("입력") or []], auto)) + return out + + +# ── ① 입력 목록 — 실제 시스템 층(읽기만) ───────────────────── + + +def test_입력_목록은_M01_조합_미리_보기와_같음(): + client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) + rows = [r for r in json.loads((store.FOLDER / store.COMBO_FILE).read_bytes())["줄"]] + with_ua = [r for r in rows if r.get("일위대가")] + picked, seen = [], set() + for r in with_ua: # 서로 다른 조합 셋 + if r["일위대가"] not in seen: + picked.append(r) + seen.add(r["일위대가"]) + if len(picked) == 3: + break + assert len(picked) == 3 + for r in picked: + res = client.get(f"/api/m02/layers/system/items/{r['키']}/inputs") + assert res.status_code == 200, res.text # 일반 사용자 읽기 + got = res.json() + want = _m01_view(r["일위대가"]) + assert got["일위대가"] == r["일위대가"] and got["이름"] + assert [lg["로직"] for lg in got["로직"]] == [k for k, _, _ in want] + for logic, (_, names, auto) in zip(got["로직"], want): + assert [i["이름"] for i in logic["입력"]] == names # 로직마다 · 합치지 않음 + assert {i["이름"]: i["자동값"] for i in logic["입력"]} == auto + assert all({"이름", "설명", "단위", "자동값"} <= set(i) for i in logic["입력"]) + # 이름이 같은 입력이 두 로직에 있어도 두 번 나옴 + names = [ + i["이름"] + for r in with_ua + for lg in client.get(f"/api/m02/layers/system/items/{r['키']}/inputs").json()["로직"] + for i in lg["입력"] + ] + assert len(names) > len(set(names)) + no_ua = next(r for r in rows if not r.get("일위대가")) + empty = client.get(f"/api/m02/layers/system/items/{no_ua['키']}/inputs").json() + assert empty == {"일위대가": None, "이름": None, "로직": [], "빠진로직": []} + assert client.get("/api/m02/layers/system/items/SS999999/inputs").status_code == 404 + + +# ── ② 연결 파일 — 임시 storage ───────────────────────── + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + return {"client": _app(session), "session": session, "storage": storage} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _put_table(client: TestClient, layer: str, edit) -> None: + url = f"/api/m02/layers/{layer}/templates/{TABLE}" + got = client.get(url).json() + edit(got["문서"]["열"]) + res = client.put(url, json={"판": got["판"], "문서": got["문서"]}) + assert res.status_code == 200, res.text + + +def _col(cid: str, ua: str | None = UA) -> dict[str, Any]: + return { + "id": cid, + "머리": ["sub4 돌쌓기", cid, None, None], + "단위": "㎡", + "꼴": "수", + "일위대가": ua, + } + + +def _key(client: TestClient, layer: str, cid: str) -> str: + items = client.get(f"/api/m02/items?layer={layer}").json() + return next(r["key"] for r in items if r["열"] == cid) + + +def test_연결_저장_다시_읽기_판_422_사라짐_알약(world): + client = _as(world) + _put_table(client, "personal", lambda cols: cols.append(_col("sub4_a"))) + key = _key(client, "personal", "sub4_a") + base = f"/api/m02/layers/personal/items/{key}" + listing = client.get(f"{base}/inputs").json() + first, second = listing["로직"][0], listing["로직"][1] + assert client.get(f"{base}/input-links").json() == {"판": None, "줄": []} + rows = [ + {"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "면적"}, + {"로직": second["로직"], "입력": second["입력"][0]["이름"], "자리": "재료"}, + {"로직": first["로직"], "입력": first["입력"][1]["이름"], "자리": "선택"}, + ] + saved = client.put(f"{base}/input-links", json={"판": None, "줄": rows}) + assert saved.status_code == 200, saved.text + assert saved.json()["판"] == 1 + again = client.get(f"{base}/input-links").json() + assert again == saved.json() + assert again["줄"][0]["알약"] == f"입력|{first['로직']}|{first['입력'][0]['이름']}" + assert "알약" not in again["줄"][2] # 선택은 시트 알약이 아님 + file = world["storage"] / "7" / "42" / "templates" / "inputs" / "sub4_a.json" + assert json.loads(file.read_text("utf-8")) == saved.json() + # 판 다르면 409 · 없는 키 · 모르는 자리 · 두 번은 422 + assert client.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 409 + for bad in ( + [{"로직": "GF999999", "입력": "높이", "자리": "면적"}], + [{"로직": first["로직"], "입력": "없는입력", "자리": "면적"}], + [{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "옆"}], + [rows[0], rows[0]], + ): + res = client.put(f"{base}/input-links", json={"판": 1, "줄": bad}) + assert res.status_code == 422, res.text + # 알약 — 면적 = 조합 물량 + 면적으로 이은 입력 · 재료 = 재료로 이은 입력만 + area = client.get(f"{base}/pills?place=면적").json() + assert area["물량"] and [p["키"] for p in area["입력"]] == [again["줄"][0]["알약"]] + mat = client.get(f"{base}/pills?place=재료").json() + assert mat["물량"] == [] and [p["입력"] for p in mat["입력"]] == [second["입력"][0]["이름"]] + assert " · " in mat["입력"][0]["이름"] # 입력 · 로직 이름(합치지 않음) + # 일위대가가 바뀌면 옛 로직 연결은 읽을 때 사라짐 · 알약에서 빠짐 + _put_table(client, "personal", lambda cols: cols[-1].update(일위대가=OTHER_UA)) + gone = client.get(f"{base}/input-links").json()["줄"] + assert all(r.get("사라짐") for r in gone) + assert client.get(f"{base}/pills?place=면적").json()["입력"] == [] + + +def test_권한_일반_사용자_읽기_쓰기(world): + admin = _as(world, "SYSTEM_ADMIN") + _put_table(admin, "system", lambda cols: cols.append(_col("sub4_sys"))) + key = _key(admin, "system", "sub4_sys") + base = f"/api/m02/layers/system/items/{key}" + user = _as(world) + assert user.get(f"{base}/inputs").status_code == 200 + assert user.get(f"{base}/input-links").status_code == 200 + assert user.put(f"{base}/input-links", json={"판": None, "줄": []}).status_code == 403 + assert _as(world, "SYSTEM_ADMIN").put(f"{base}/input-links", json={"줄": []}).status_code == 200 + # 회사 층 — 같은 회사 일반 사용자 읽기 · 쓰기 403 · 회사 관리자 쓰기 + _put_table(_as(world, "ADMIN"), "company", lambda cols: cols.append(_col("sub4_c"))) + ckey = _key(_as(world), "company", "sub4_c") + cbase = f"/api/m02/layers/company/items/{ckey}" + assert _as(world).get(f"{cbase}/inputs").status_code == 200 + assert _as(world).put(f"{cbase}/input-links", json={"줄": []}).status_code == 403 + assert _as(world, "ADMIN").put(f"{cbase}/input-links", json={"줄": []}).status_code == 200 + # 남의 개인 층은 못 읽음 + assert _as(world).get(f"{cbase}/inputs?owner=9").status_code == 403 + + +def test_열_지우기_항목_복사_분류_복사가_연결을_같이(world): + admin = _as(world, "ADMIN") + _put_table(admin, "company", lambda cols: cols.append(_col("sub4_c"))) + key = _key(admin, "company", "sub4_c") + base = f"/api/m02/layers/company/items/{key}" + logic = admin.get(f"{base}/inputs").json()["로직"][0] + rows = [{"로직": logic["로직"], "입력": logic["입력"][0]["이름"], "자리": "면적"}] + assert admin.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 200 + # 항목 개인 복사 — 새 열에 연결이 판 1 로 + user = _as(world) + made = user.post( + "/api/m02/layers/personal/items/copy", json={"from_layer": "company", "key": key} + ).json() + mine = user.get(f"/api/m02/layers/personal/items/{made['key']}/input-links").json() + assert mine["판"] == 1 and [r["입력"] for r in mine["줄"]] == [rows[0]["입력"]] + # 분류 더하기(본뜬열) — 새 열이 원본 열 연결을 받음 + admin = _as(world, "ADMIN") + twin = {**_col("sub4_c2", None), "본뜬열": "sub4_c"} + _put_table(admin, "company", lambda cols: cols.append(twin)) + folder = world["storage"] / "7" / "templates" + with store.at(folder): + assert inputs_store.read_file("sub4_c2")["줄"] == inputs_store.read_file("sub4_c")["줄"] + # 열 지우기 — 연결 파일도 같이 지움 + _put_table( + admin, "company", lambda cols: cols.remove(next(c for c in cols if c["id"] == "sub4_c")) + ) + assert not (folder / "inputs" / "sub4_c.json").is_file() + assert (folder / "inputs" / "sub4_c2.json").is_file() From db1bb9aa872e1dd578133717c5f69f189ea3f38c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:18:13 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=84=B7=EC=A7=B8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=E3=80=8C=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=A1=9C=EC=A7=81=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0(=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=C2=B7=20=EC=95=88=20=EC=94=80)=20=C2=B7=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EC=A4=84=EC=97=90=20=EB=9C=B8=20(PLAN=2025-2c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 = 로직 이름 · 결과단위 묶음 아래 입력 줄(이름 · 설명 · 단위 · 고르기/범위 · 자동값 흐리게) + 자리 상자 · 처음은 모두 안 씀 - 저장 = 집계표 [저장]에 이어 PUT input-links(자동저장 없음) · 읽기만 층은 상자 잠금 - 면적 · 재료로 고른 입력은 그 시트 알약 줄에 「로직 이름 · 입력 이름」 · 자재 모드에서도 [목록에서 빼기] 막음(고정) - 알약 줄 시험을 합계 줄 아래 자리에 맞춤(25-0 뒤 어긋난 것) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- A00_Common/spreadsheet/spreadsheet_links.ts | 4 +- .../M02_MasterTemplete_Api_Fetch.ts | 70 +++++ .../M02_MasterTemplete_Inputs.css | 105 +++++++ .../M02_MasterTemplete_Inputs.ts | 278 ++++++++++++++++++ .../M02_MasterTemplete_Inputs_Model.ts | 103 +++++++ .../M02_MasterTemplete_Structure.css | 2 +- .../M02_MasterTemplete_Structure.ts | 27 +- .../M02_MasterTemplete_UI_Main.ts | 8 +- resources/tester/helper_m02_inputs.cjs | 56 ++++ .../spreadsheet/test_spreadsheet_d_input.py | 6 +- resources/tester/test_m02_inputs.py | 80 +++++ 11 files changed, 728 insertions(+), 11 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts create mode 100644 resources/tester/helper_m02_inputs.cjs create mode 100644 resources/tester/test_m02_inputs.py diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index e81717ff..963f0a68 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -35,6 +35,8 @@ export interface LinkItem { 단위?: string; /** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */ 자재?: LinkMaterial; + /** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */ + 고정?: boolean; } export interface LinkOptions { @@ -208,7 +210,7 @@ export function mountLinks( }; b.addEventListener("contextmenu", (e) => { e.preventDefault(); - const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키); + const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); if (ctx.readOnly || !(hit || inList)) return; const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게 menu.open(e.clientX - o.left, e.clientY - o.top, [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 95d6bd83..52499b89 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -333,3 +333,73 @@ export const fetchSources = (id: string): Promise => call(`/projects/${ /** 설계값을 채운 표 문서 — 서버가 그때그때 채움 · 저장 안 함 */ export const readFilled = (id: string, name: string): Promise => call(`/projects/${enc(id)}/tables/${enc(name)}/filled`); + +/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 --- + * 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */ + +/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */ +export interface ItemRow { + key: string; + 열: string; + 일위대가: string | null; +} + +/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */ +export interface ItemInput { + 이름: string; + 설명?: string; + 단위?: string; + 고르기?: unknown[]; + 범위?: unknown; + 자동값?: unknown; +} + +export interface ItemLogic { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: ItemInput[]; +} + +export interface ItemInputs { + 일위대가: string | null; + 이름?: string; + 로직: ItemLogic[]; + 빠진로직: string[]; +} + +/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */ +export type InputSeat = "면적" | "재료" | "선택"; + +export interface InputLine { + 로직: string; + 입력: string; + 자리: InputSeat | null; + 알약?: string; +} + +export interface InputLinks { + 판: string | null; + 줄: InputLine[]; +} + +const itemPath = (layer: Layer, key: string, tail: string): string => + `/layers/${layer}/items/${enc(key)}/${tail}`; + +export const fetchItemRows = (layer: Layer): Promise => + call(`/items?layer=${layer}`); + +export const fetchItemInputs = (layer: Layer, key: string): Promise => + call(itemPath(layer, key, "inputs")); + +export const readInputLinks = (layer: Layer, key: string): Promise => + call(itemPath(layer, key, "input-links")); + +/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */ +export const saveInputLinks = ( + layer: Layer, + key: string, + 판: string | null, + 줄: InputLine[], +): Promise<{ 판: string }> => + call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css new file mode 100644 index 00000000..fff107c9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -0,0 +1,105 @@ +/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */ + +.m02-inputs__summary { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + align-items: baseline; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-inputs__count { + color: var(--color-text); + font-weight: 600; +} + +/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */ +.m02-inputs__body { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: var(--spacing-12); + min-height: var(--m02-struct-body-min); + min-width: 0; + overflow: auto; + padding: var(--spacing-8) 0; +} + +.m02-inputs__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-inputs__logic-title { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: baseline; + margin: 0; + padding-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-body-sm); + overflow-wrap: anywhere; +} + +.m02-inputs__logic-title small, +.m02-inputs__row small { + color: var(--color-text-muted); + font-size: var(--text-caption); + font-weight: 400; +} + +.m02-inputs__row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + justify-content: space-between; + padding: var(--spacing-4) 0; +} + +.m02-inputs__info { + display: flex; + flex: 1 1 12rem; + flex-wrap: wrap; + gap: 2px var(--spacing-8); + align-items: baseline; + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-inputs__name { + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.m02-inputs__desc, +.m02-inputs__hint { + flex-basis: 100%; +} + +/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */ +.m02-inputs__auto { + flex-basis: 100%; + opacity: 0.6; + font-style: italic; +} + +.m02-inputs__seat { + flex: none; + max-width: 100%; + padding: 4px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 4px); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: var(--text-body-sm); +} + +.m02-inputs__seat:disabled { + opacity: 0.7; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts new file mode 100644 index 00000000..3462dd1b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -0,0 +1,278 @@ +/* ============================================================================= + * M02_MasterTemplete_Inputs.ts + * 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어 + * 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름. + * + * · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로. + * · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b). + * · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄 + * (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음). + * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; +import { + fetchItemInputs, + fetchItemRows, + getActiveLayer, + readInputLinks, + saveInputLinks, + type InputSeat, + type ItemInputs, + type ItemRow, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; +import { + autoText, + countOf, + inputKey, + linesOf, + pillsOf, + seatsOf, + SEAT_OPTIONS, + valueHint, + type SeatMap, +} from "./M02_MasterTemplete_Inputs_Model"; +import "./M02_MasterTemplete_Inputs.css"; + +const TEXT = { + title: "선택 입력", + count: "로직 {logics} · 입력 {inputs}", + chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}", + auto: "자동값", + noItem: "집계표를 먼저 저장하면 입력 목록이 나옴", + noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름", + unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴", + noLogic: "이 일위대가에 담긴 로직이 없음", + missing: "빠진 로직 {value}", + failed: "입력 목록을 못 받음 — {value}", + loading: "입력 목록을 받는 중…", + seat: "자리", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */ +interface Draft { + key: string; + 판: string | null; + list: ItemInputs; + seats: SeatMap; + dirty: boolean; +} + +export interface InputsCardOptions { + /** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */ + isAdmin: boolean; + /** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */ + onDirty: () => void; +} + +export interface InputsCardHandle { + root: HTMLElement; + /** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */ + show: (colId: string | null, combo: string | null) => Promise; + /** 고른 게 저장 안 됐나 */ + dirty: () => boolean; + /** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */ + save: () => Promise; + /** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */ + invalidate: () => void; + destroy: () => void; +} + +export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { + const drafts = new Map(); + let rows: Promise | null = null; + let seq = 0; + + const summary = el("div", { className: "m02-inputs__summary" }); + const body = el("div", { className: "m02-inputs__body" }); + const root = el("section", { + className: "m02-struct__card m02-inputs ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), summary, body], + }); + + const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); + const clear = (): void => { + summary.replaceChildren(); + body.replaceChildren(); + }; + + const itemRows = (): Promise => + (rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => { + rows = null; + throw error; + })); + + function paintSummary(draft: Draft): void { + const count = countOf(draft.list); + // 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐) + const listed = new Set( + draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))), + ); + const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat); + const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length; + summary.replaceChildren( + el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }), + el("span", { + className: "m02-inputs__chosen", + text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }), + }), + ); + } + + function paint(draft: Draft): void { + paintSummary(draft); + const groups = draft.list.로직.map((logic) => + el("div", { + className: "m02-inputs__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputs__logic-title", + children: [ + el("span", { text: logic.이름 }), + ...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []), + el("small", { className: "m02-inputs__logic-key", text: logic.로직 }), + ], + }), + ...logic.입력.map((input) => { + const key = inputKey(logic.로직, input.이름); + const select = el("select", { + className: "m02-inputs__seat", + attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` }, + children: SEAT_OPTIONS.map((o) => + el("option", { text: o.label, attrs: { value: o.value } }), + ), + }) as HTMLSelectElement; + select.value = draft.seats.get(key) ?? ""; + select.disabled = !opts.isAdmin; + select.addEventListener("change", () => { + if (select.value) draft.seats.set(key, select.value as InputSeat); + else draft.seats.delete(key); + draft.dirty = true; + paintSummary(draft); + opts.onDirty(); + }); + const hint = valueHint(input); + const auto = autoText(input); + return el("div", { + className: "m02-inputs__row", + attrs: { "data-input": input.이름 }, + children: [ + el("div", { + className: "m02-inputs__info", + children: [ + el("span", { className: "m02-inputs__name", text: input.이름 }), + ...(input.단위 ? [el("small", { text: input.단위 })] : []), + ...(input.설명 + ? [el("small", { className: "m02-inputs__desc", text: input.설명 })] + : []), + ...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []), + ...(auto + ? [ + el("small", { + className: "m02-inputs__auto", + text: `${TEXT.auto} ${auto}`, + }), + ] + : []), + ], + }), + select, + ], + }); + }), + ], + }), + ); + const missing = draft.list.빠진로직; + body.replaceChildren( + ...(groups.length ? groups : [note(TEXT.noLogic)]), + ...(missing.length ? [note(fill(TEXT.missing, { value: missing.join(", ") }))] : []), + ); + } + + async function show(colId: string | null, combo: string | null): Promise { + const mine = ++seq; + clear(); + if (!colId) return; + const kept = drafts.get(colId); + if (kept) return paint(kept); + body.replaceChildren(note(TEXT.loading)); + try { + const layer: Layer = getActiveLayer(); + const row = (await itemRows()).find((r) => r.열 === colId); + if (mine !== seq) return; + if (!row) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noItem)); + if (!row.일위대가) + return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noCombo)); + const [list, links] = await Promise.all([ + fetchItemInputs(layer, row.key), + readInputLinks(layer, row.key), + ]); + if (mine !== seq) return; + if (!list.일위대가) return void body.replaceChildren(note(TEXT.noCombo)); + const draft: Draft = { + key: row.key, + 판: links.판, + list, + seats: seatsOf(links), + dirty: false, + }; + drafts.set(colId, draft); + paint(draft); + } catch (error) { + if (mine === seq) body.replaceChildren(note(fill(TEXT.failed, { value: why(error) }))); + } + } + + return { + root, + show, + dirty: () => [...drafts.values()].some((d) => d.dirty), + save: async () => { + const layer = getActiveLayer(); + for (const draft of drafts.values()) { + if (!draft.dirty) continue; + const got = await saveInputLinks( + layer, + draft.key, + draft.판, + linesOf(draft.list, draft.seats), + ); + draft.판 = got.판; + draft.dirty = false; + } + }, + invalidate: () => { + rows = null; + for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음 + }, + destroy: () => { + seq += 1; + root.remove(); + }, + }; +} + +/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */ +export async function inputPills(column: string, seat: "면적" | "재료"): Promise { + const layer = getActiveLayer(); + if (layer === "project") return []; + try { + const row = (await fetchItemRows(layer)).find((r) => r.열 === column); + if (!row?.일위대가) return []; + const [list, links] = await Promise.all([ + fetchItemInputs(layer, row.key), + readInputLinks(layer, row.key), + ]); + return pillsOf(list, links, seat); + } catch { + return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로 + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts new file mode 100644 index 00000000..f6baf434 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M02_MasterTemplete_Inputs_Model.ts + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · + * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. + * ========================================================================== */ + +import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; +import type { + InputLine, + InputLinks, + InputSeat, + ItemInput, + ItemInputs, +} from "./M02_MasterTemplete_Api_Fetch"; + +/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */ +export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [ + { value: "", label: "안 씀" }, + { value: "면적", label: "면적산출 알약" }, + { value: "재료", label: "재료산출 알약" }, + { value: "선택", label: "선택" }, +]; + +export const inputKey = (logic: string, name: string): string => `${logic}|${name}`; +export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`; + +/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */ +export type SeatMap = Map; + +export function seatsOf(links: InputLinks | null): SeatMap { + const out: SeatMap = new Map(); + for (const line of links?.줄 ?? []) + if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리); + return out; +} + +/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */ +export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] { + const out: InputLine[] = []; + for (const logic of list.로직) + for (const input of logic.입력) { + const seat = seats.get(inputKey(logic.로직, input.이름)); + if (!seat) continue; + out.push({ + 로직: logic.로직, + 입력: input.이름, + 자리: seat, + ...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }), + }); + } + return out; +} + +export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({ + logics: list.로직.length, + inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0), +}); + +/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */ +export function pillsOf( + list: ItemInputs, + links: InputLinks | null, + seat: "면적" | "재료", +): LinkItem[] { + const seats = seatsOf(links); + const out: LinkItem[] = []; + for (const logic of list.로직) + for (const input of logic.입력) { + if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue; + out.push({ + 키: pillKey(logic.로직, input.이름), + 이름: `${logic.이름} · ${input.이름}`, + ...(input.단위 ? { 단위: input.단위 } : {}), + 고정: true, + }); + } + return out; +} + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index e291744e..de26589e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -13,7 +13,7 @@ display: grid; flex: 1 1 0; /* 메인 칸(창 높이)에서 표 아래 남은 높이를 다 차지 */ grid-auto-rows: minmax(auto, 1fr); /* 카드 셋 같은 높이 */ - grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); padding-top: var(--spacing-12); border-top: 1px solid var(--color-border); min-width: 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 45059bc7..ab0871e3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -42,6 +42,7 @@ import { type DrawingTemplateDoc, type DrawingTemplateHandle, } from "./M02_MasterTemplete_Drawing"; +import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ @@ -207,8 +208,12 @@ export async function pillsFor( kind: SheetKind, column: string, ): Promise<{ links: LinkOptions; notice?: string }> { - if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } }; - const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice }); + if (kind === "material") { + const own = await inputPills(column, "재료"); + return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } }; + } + const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력 + const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice }); const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch( () => null, ); @@ -217,7 +222,7 @@ export async function pillsFor( if (!combo) return none(TEXT.noPrice); try { const got = await fetchComboInputs(combo); - const 항목 = got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })); + const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own]; return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo)); } catch (error) { return none(fill(TEXT.inputsFailed, why(error))); @@ -232,6 +237,8 @@ export interface StructurePanelOptions { onEdit: (column: string, label: string, kind: EditKind) => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; + /** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */ + onDirty?: () => void; } export interface StructurePanelHandle { @@ -240,6 +247,8 @@ export interface StructurePanelHandle { numbers: () => ColumnNumbers; /** 번호 목록 다시 받기 */ reload: () => Promise; + /** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */ + saveInputs: () => Promise; /** 고른 열이 바뀜 — 같은 열이면 이름만 새로(CAD 를 다시 안 띄움) */ show: (colId: string | null, columns: StructureColumn[]) => void; /** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */ @@ -311,11 +320,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; }; const sheets = [sheetCard("area"), sheetCard("material")]; + const inputs: InputsCardHandle = createInputsCard({ + isAdmin: opts.isAdmin, + onDirty: () => opts.onDirty?.(), + }); const root = el("div", { className: "m02-struct ui-sidebar-panel", attrs: { hidden: "" }, - children: [infoCard, ...sheets.map((c) => c.root)], + children: [infoCard, ...sheets.map((c) => c.root), inputs.root], }); attachCollapsible(root); @@ -397,6 +410,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane root.hidden = true; column = null; doc = null; + void inputs.show(null, null); return; } root.hidden = false; @@ -427,6 +441,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane c.number = String(book?.[PARTS[c.kind].field] ?? ""); }); paintBody(mine); + // 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름 + void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null); } /** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */ @@ -516,7 +532,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane numbers: () => numbers, reload: async () => { numbers = await fetchStructureNumbers().catch(() => ({})); + inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게 }, + saveInputs: () => inputs.save(), show, refresh: (columns) => { const at = column; @@ -527,6 +545,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane destroy: () => { seq += 1; drop(); + inputs.destroy(); root.remove(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 76bc23f3..cad56f18 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -23,6 +23,7 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; +import { inputPills } from "./M02_MasterTemplete_Inputs"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { @@ -197,6 +198,7 @@ export function createMain( const made = createStructurePanel({ isAdmin, confirmLeave, + onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장 // 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶 onEdit: (column, label, kind) => { void open({ layer, projectId: null, kind, name: column, label }); @@ -221,16 +223,17 @@ export function createMain( try { const master = at.layer !== "project" && kind === "material"; if (master && !matState) matState = await loadMaterialState(at.name); - const [{ createSpreadsheet }, pills] = await Promise.all([ + const [{ createSpreadsheet }, pills, own] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), master ? null : pillsFor(kind, at.name), + master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력 ]); if (mine !== seq) return; if (pills?.notice) showNotice([pills.notice]); const links: LinkOptions = pills?.links ?? ({ - 항목: toPills(pillItems ?? matState?.combo?.담은자재 ?? []), + 항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own], 모드: "자재", 자재찾기: searchMaterials, // 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로 @@ -468,6 +471,7 @@ export function createMain( await panel.reload(); sheet.recalc(); panel.refresh(sheet.getDoc()?.열 ?? []); + await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서 } showNotice([]); showToast(L("M02_Saved"), "success"); diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs new file mode 100644 index 00000000..6f014a6a --- /dev/null +++ b/resources/tester/helper_m02_inputs.cjs @@ -0,0 +1,56 @@ +/* M02 「선택 입력」 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`) 단위검증 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +// 같은 이름(높이)의 입력이 두 로직에 있음 +const list = { + 일위대가: "UA000001", + 로직: [ + { + 로직: "GC1", + 이름: "돌쌓기", + 입력: [ + { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }, + { 이름: "종류", 고르기: ["막돌", "견치돌"] }, + ], + }, + { 로직: "GC2", 이름: "타설", 입력: [{ 이름: "높이", 범위: { 최소: 0, 최대: 3 } }, { 이름: "배합" }] }, + ], + 빠진로직: [], +}; +const seats = m.seatsOf({ + 판: "v1", + 줄: [ + { 로직: "GC1", 입력: "높이", 자리: "면적", 알약: "입력|GC1|높이" }, + { 로직: "GC2", 입력: "높이", 자리: "면적" }, + { 로직: "GC2", 입력: "배합", 자리: "재료" }, + { 로직: "GC1", 입력: "종류", 자리: null }, + { 로직: "GC9", 입력: "없는것", 자리: "선택" }, + ], +}); +const links = { 판: "v1", 줄: [...[...seats].map(([k, 자리]) => ({ 로직: k.split("|")[0], 입력: k.split("|")[1], 자리 }))] }; +const pick = new Map(seats); +pick.set(m.inputKey("GC1", "종류"), "선택"); +process.stdout.write( + JSON.stringify({ + keys: [m.inputKey("GC1", "높이"), m.pillKey("GC2", "높이")], + seatKeys: [...seats.keys()], + count: m.countOf(list), + area: m.pillsOf(list, links, "면적").map((p) => [p.키, p.이름, p.단위 ?? null, p.고정]), + material: m.pillsOf(list, links, "재료").map((p) => [p.키, p.이름]), + lines: m.linesOf(list, pick), + none: m.linesOf(list, new Map()), + hints: list.로직.flatMap((l) => l.입력).map((i) => [m.valueHint(i), m.autoText(i)]), + seatOptions: m.SEAT_OPTIONS.map((o) => o.label), + }), +); diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index bb765680..ebd4f575 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -113,13 +113,13 @@ def test_links_follow_rows_cols(r): assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"] -def test_links_bar_sits_under_sheet(): - # 알약 막대 = 시트 아래 · 상태줄 위 · 자재 고르기 창은 막대 위로 엶 +def test_links_bar_sits_under_statusbar(): + # 알약 막대 = 시트 · 합계 줄(상태줄) 아래 맨 끝 · 자재 고르기 창은 막대 위로 엶 with open( os.path.join(HERE, "../../../A00_Common/spreadsheet/spreadsheet_links.ts"), encoding="utf-8" ) as f: src = f.read() - assert 'insertBefore(bar, ctx.root.querySelector(":scope > .ss-statusbar"))' in src + assert 'ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar)' in src assert "pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`" in src diff --git a/resources/tester/test_m02_inputs.py b/resources/tester/test_m02_inputs.py new file mode 100644 index 00000000..a6b87549 --- /dev/null +++ b/resources/tester/test_m02_inputs.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""M02 「선택 입력」 카드(PLAN 25-2c) 셈 회귀검증 — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + +같은 이름의 입력도 로직마다 따로(키 `{로직}|{이름}`) · 처음은 모두 「안 씀」(저장 줄 없음) · +면적 · 재료로 고른 입력만 그 시트 알약(`입력|{로직}|{이름}` · 「로직 이름 · 입력 이름」). +Node 헬퍼(`helper_m02_inputs.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(HERE, "..", "..") + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_inputs.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_키_모양(results): + assert results["keys"] == ["GC1|높이", "입력|GC2|높이"] + + +def test_자리는_고른_것만_안_쓰면_없음(results): + assert results["seatKeys"] == ["GC1|높이", "GC2|높이", "GC2|배합", "GC9|없는것"] + assert results["none"] == [] # 처음은 모두 안 씀 — 저장할 줄 없음 + + +def test_로직_수_입력_수(results): + assert results["count"] == {"logics": 2, "inputs": 4} + + +def test_같은_이름_입력도_로직마다_따로_알약(results): + # 높이가 두 로직에 있어도 알약 둘 · 이름은 「로직 이름 · 입력 이름」 · 목록에서 못 뺌(고정) + assert results["area"] == [ + ["입력|GC1|높이", "돌쌓기 · 높이", "m", True], + ["입력|GC2|높이", "타설 · 높이", None, True], + ] + assert results["material"] == [["입력|GC2|배합", "타설 · 배합"]] + + +def test_저장_줄은_고른_것만_알약_키_함께(results): + assert results["lines"] == [ + {"로직": "GC1", "입력": "높이", "자리": "면적", "알약": "입력|GC1|높이"}, + {"로직": "GC1", "입력": "종류", "자리": "선택"}, # 선택은 알약 없음 + {"로직": "GC2", "입력": "높이", "자리": "면적", "알약": "입력|GC2|높이"}, + {"로직": "GC2", "입력": "배합", "자리": "재료", "알약": "입력|GC2|배합"}, + ] + + +def test_고르기_범위_자동값_글(results): + assert results["hints"] == [ + ["0.5 ~ 5", "1.2"], + ["막돌 / 견치돌", ""], + ["0 ~ 3", ""], + ["", ""], + ] + assert results["seatOptions"] == ["안 씀", "면적산출 알약", "재료산출 알약", "선택"] + + +def test_카드는_집계표_카드_옆_넷째_그리고_700줄_안(): + page = os.path.join(ROOT, "M02_MasterTemplete") + src = open(os.path.join(page, "M02_MasterTemplete_Structure.ts"), encoding="utf-8").read() + assert "inputs.root" in src and "createInputsCard" in src + for name in os.listdir(page): + if name.endswith((".ts", ".py")): + n = len(open(os.path.join(page, name), encoding="utf-8").read().splitlines()) + assert n <= 700, (name, n) From 470d00a18c885acd4681991a6035b33399463425 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:18:47 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(M02):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EA=B3=B5=EC=9A=A9=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=EB=A1=9C=20=E2=80=94=20=EC=9E=90=EB=A3=8C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=B0=BD=EA=B5=AC=20=C2=B7=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=85=8B=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=B9=B4=EB=93=9C=20=EB=BC=88=EB=8C=80=20?= =?UTF-8?q?(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemSource: 문서를 읽는 자리를 인자로(M02 = 지금 층 · 고르기 모달 = 프로젝트 복사본) - ItemCards: Structure.ts 의 도면 · 면적산출 · 재료산출 카드 그리기를 옮김(DOM · 동작 그대로) + 덧붙는 카드 자리 - InputsCard: 선택 입력 카드 뼈대(로직마다 묶음 · view · value 모드 · link 는 25-2c) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M02_MasterTemplete_InputsCard.css | 93 +++++ .../M02_MasterTemplete_InputsCard.ts | 389 ++++++++++++++++++ .../M02_MasterTemplete_ItemCards.ts | 277 +++++++++++++ .../M02_MasterTemplete_ItemSource.ts | 29 ++ .../M02_MasterTemplete_Structure.ts | 241 ++--------- resources/tester/test_m02_structure_panel.py | 9 +- 6 files changed, 827 insertions(+), 211 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css new file mode 100644 index 00000000..f8561d0d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css @@ -0,0 +1,93 @@ +/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 · 값 칸 · 확인 표시. 카드 틀은 M02_MasterTemplete_Structure.css */ +.m02-inputs__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputs { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputs__places, +.m02-inputs__missing { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m02-inputs__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputs__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputs__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputs__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputs__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputs__control { + flex: 1 1 6rem; + min-width: 0; +} + +/* 자동값 = 저장값이 아님 — 흐리게 */ +.m02-inputs__value.is-auto, +.m02-inputs__control.is-auto { + color: var(--color-text-muted); + font-style: italic; +} + +.m02-inputs__state { + color: var(--color-text-muted); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputs__state.is-confirmed { + color: var(--color-success, var(--color-text)); +} + +.m02-inputs__error { + flex: 1 0 100%; + color: var(--color-danger, var(--color-text)); + font-size: var(--text-caption); +} + +.m02-inputs__control[aria-invalid="true"] { + border-color: var(--color-danger, currentcolor); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts new file mode 100644 index 00000000..9bcbcb51 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts @@ -0,0 +1,389 @@ +/* ============================================================================= + * M02_MasterTemplete_InputsCard.ts + * 「선택 입력」 카드(계획서 25장) — 구조물 집계 항목의 일위대가 조합이 담은 단가산출 로직들의 입력을 + * **로직마다 묶어** 보이는 카드. 카드 틀은 도면 · 면적산출 · 재료산출 카드와 같음(`ItemCards`). + * + * mode "view" = 보기만 — 자리가 「선택」 인 입력의 값(없으면 흐린 자동값 + 「확인 안 함」) + * mode "value" = 값 받기 — 프로젝트 복사본(고르기 = 목록 · 범위 = 숫자 · 자동값 흐림 · 확인 표시) + * mode "link" = 자리 고르기(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀) — M02 세 층 공용(sub2 · 25-2c) + * + * 값은 로직마다 따로 — 이름이 같아도 합치지 않음(`{로직}|{입력이름}`). 입력이 없어도 자동값은 흐린 + * 미리보기일 뿐 저장값이 아님(25-1: 확인 안 한 입력은 계산 때 「빠진 입력」 목록). + * 자료 모양은 25-1b 약속 그대로 — 아래 타입. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { itemCard, itemNote } from "./M02_MasterTemplete_ItemCards"; +import "./M02_MasterTemplete_InputsCard.css"; + +/** 입력 목록 한 입력(25-1b) — `고르기` 는 문자열 목록 · 객체 목록 둘 다 받음 */ +export interface LogicInput { + 이름: string; + 설명?: string | null; + 단위?: string | null; + 고르기?: Array | null; + 범위?: [number, number] | { 최소?: number; 최대?: number; min?: number; max?: number } | null; + 자동값?: string | number | null; +} + +export interface InputsLogic { + 로직: string; + 이름: string; + 결과단위?: string | null; + 입력: LogicInput[]; +} + +/** `GET /api/m02/layers/{layer}/items/{key}/inputs` */ +export interface InputsList { + 일위대가: string; + 이름: string; + 로직: InputsLogic[]; + 빠진로직: string[]; +} + +export type InputPlace = "면적" | "재료" | "선택" | null; + +/** 항목 연결 한 줄 — 자리 면적 · 재료 = 그 시트 알약 · 선택 = 이 카드 */ +export interface LinkRow { + 로직: string; + 입력: string; + 자리: InputPlace; + 알약?: string; +} + +export interface LinkDoc { + 판: string; + 줄: LinkRow[]; +} + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` */ +export type ValueDoc = Record< + string, + Record +>; + +/** 입력 하나의 키 · 알약 키(25-1b) */ +export const inputKey = (logic: string, name: string): string => `${logic}|${name}`; +export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`; + +const TEXT = { + title: "선택 입력", + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + missing: "없는 로직 {value}", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "확인함", + confirmBox: "확인", + outOfRange: "범위 {value} 밖", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); +const num = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 */ +export function rangeOf(input: LogicInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: num(raw[0]), max: num(raw[1]) }; + if (raw && typeof raw === "object") + return { min: num(raw.최소 ?? raw.min), max: num(raw.최대 ?? raw.max) }; + return null; +} + +/** 고르기 목록 → 값 · 이름 짝 */ +export function choicesOf(input: LogicInput): { 값: string; 이름: string }[] { + return (input.고르기 ?? []).map((one) => + typeof one === "object" + ? { 값: String(one.값 ?? one.이름 ?? ""), 이름: String(one.이름 ?? one.값 ?? "") } + : { 값: String(one), 이름: String(one) }, + ); +} + +/** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ +export function pickedGroups( + list: InputsList, + link: LinkDoc | null, +): { logic: InputsLogic; inputs: LogicInput[] }[] { + const picked = new Set( + (link?.줄 ?? []) + .filter((row) => row.자리 === "선택") + .map((row) => inputKey(row.로직, row.입력)), + ); + return list.로직 + .map((logic) => ({ + logic, + inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))), + })) + .filter((group) => group.inputs.length > 0); +} + +export interface InputsCardData { + list: InputsList; + link: LinkDoc | null; + values: ValueDoc; +} + +export interface InputsCardOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsCardHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsCardData | null, note?: string) => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 프로젝트로 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; + /** 자리 「선택」 인 입력 수 · 확인한 수 */ + counts: () => { total: number; confirmed: number }; +} + +export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { + let data: InputsCardData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputs__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputs" }); + const root = itemCard( + TEXT.title, + el("div", { + className: "m02-struct__subhead", + children: [summary, edit], + }), + el("div", { className: "m02-inputs__wrap", children: [places, body] }), + ); + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const valueOf = (logic: string, name: string): { 값: string | number | null; 확인: boolean } => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const store = ( + logic: string, + name: string, + next: { 값: string | number | null; 확인: boolean }, + ) => { + (values[logic] ??= {})[name] = next; + }; + + const counts = (): { total: number; confirmed: number } => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (valueOf(logic.로직, input.이름).확인) confirmed += 1; + } + return { total, confirmed }; + }; + const paintSummary = (): void => { + const { total, confirmed } = counts(); + summary.textContent = data ? `${fill(TEXT.summary, `${confirmed}/${total}`)}` : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(placeholder · 첫 줄) */ + const valueCell = (logic: InputsLogic, input: LogicInput): HTMLElement => { + const stored = valueOf(logic.로직, input.이름); + const auto = input.자동값 ?? null; + const state = el("span", { className: "m02-inputs__state" }); + const paintState = (): void => { + const now = valueOf(logic.로직, input.이름); + state.textContent = now.확인 ? TEXT.confirmed : TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputs__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputs__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const choices = choicesOf(input); + const control: HTMLInputElement | HTMLSelectElement = choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputs__control" }) + : el("input", { + className: "ui-input m02-inputs__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }); + const error = el("span", { className: "m02-inputs__error" }); + const box = el("input", { + attrs: { type: "checkbox", title: TEXT.confirmBox, "aria-label": TEXT.confirmBox }, + }); + + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${TEXT.auto} ${String(auto)}`, + }), + ...choices.map((one) => el("option", { attrs: { value: one.값 }, text: one.이름 })), + ); + control.value = isEmpty(stored.값) ? "" : String(stored.값); + } else { + control.value = isEmpty(stored.값) ? "" : String(stored.값); + } + control.classList.toggle("is-auto", isEmpty(stored.값)); + box.checked = stored.확인; + + const outside = (raw: string): boolean => { + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); + }; + let wasBad = false; + const changed = (): void => { + const raw = control.value; + const bad = outside(raw); + if (bad !== wasBad) invalid += bad ? 1 : -1; + wasBad = bad; + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`) + : ""; + control.classList.toggle("is-auto", raw === ""); + const value: string | number | null = raw === "" ? null : range ? Number(raw) : raw; + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + store(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); + box.checked = raw !== "" && !bad; + emit(); + }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = valueOf(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + store(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + store(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { + className: "m02-inputs__cell", + children: [control, box, state, error], + }); + }; + + const paint = (note?: string): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + body.replaceChildren(itemNote(note ? fill(TEXT.noList, note) : TEXT.none)); + return; + } + const rows = data.link?.줄 ?? []; + const area = rows.filter((row) => row.자리 === "면적").length; + const material = rows.filter((row) => row.자리 === "재료").length; + places.textContent = + area + material + ? TEXT.places.replace("{area}", `${area}`).replace("{material}", `${material}`) + : ""; + const shownGroups = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputs__missing", + text: fill(TEXT.missing, data.list.빠진로직.join(", ")), + }), + ] + : []; + if (!shownGroups.length) { + body.replaceChildren(itemNote(TEXT.none), ...missing); + return; + } + body.replaceChildren( + ...shownGroups.map(({ logic, inputs }) => + el("section", { + className: "m02-inputs__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputs__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputs__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputs__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note); + }, + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, one] of Object.entries(byName)) + if (one.확인 || !isEmpty(one.값)) (out[logic] ??= {})[name] = one; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + counts, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts new file mode 100644 index 00000000..394742f8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -0,0 +1,277 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemCards.ts + * 항목 카드 — 도면 정보 · 면적산출 · 재료산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …). + * M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기 + * 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는 + * 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨. 모양(클래스 · DOM)은 옮기기 전과 같음. + * 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임). + * 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_InputsCard.ts`)가 그 자리. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch"; +import { + mountDrawingTemplate, + type DrawingTemplateDoc, + type DrawingTemplateHandle, +} from "./M02_MasterTemplete_Drawing"; +import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource"; +import "./M02_MasterTemplete_Structure.css"; + +/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */ +export type EditKind = ItemPart; +export type SheetKind = Exclude; +export const PART_FIELD: Record = { + structure: "도번", + area: "면적번", + material: "재료번", +}; +export const PART_WORD: Record = { + structure: "도면", + area: "면적산출", + material: "재료산출", +}; + +const TEXT = { + drawingInfo: "도면 정보", + edit: "도면 수정", + noNumber: "도번 없음", + noDrawing: "도면 없음", + editSheet: "{value} 수정", + noSheetDoc: "{value} 없음", + noFieldNumber: "{value} 없음", + noSheet: "스프레드시트를 못 띄움 — {value}", + readFailed: "구조물 도면을 못 읽음 — {value}", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const fill = (text: string, value: string): string => text.replace("{value}", value); +/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ +const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); + +/** 카드 틀 — 제목 + 머리 + 본문(좌측 패널과 같은 공용 ui-sidebar 카드) */ +export const itemCard = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement => + el("section", { + className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body], + }); + +export const itemNote = (text: string): HTMLElement => + el("p", { className: "m02-struct__empty", text }); + +export interface ItemCardsOptions { + /** 지금 자료 자리 — 열을 읽을 때마다 부름(층이 바뀌어도 따라감) */ + source: () => ItemSource; + /** [수정] 단추를 보일까 — 부를 때마다 다시 봄 */ + editable: () => boolean; + /** [도면 수정] · [면적산출 수정] · [재료산출 수정] */ + onEdit: (kind: EditKind) => void; + /** 문서에 번호가 없을 때 쓸 번호(집계표 번호 줄) */ + numberOf?: (kind: EditKind, column: string) => string | undefined; + /** 도면을 못 읽었을 때 알릴까 — 기본 알림(M02 는 번호가 있는 열만) */ + warnOnFail?: (column: string) => boolean; + /** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */ + extraCards?: HTMLElement[]; +} + +export interface ItemCardsHandle { + root: HTMLElement; + /** 그 열을 읽어 카드를 그림 — `null` 이면 카드를 숨김 */ + load: (col: { id: string; label: string } | null) => Promise; + /** 이름만 새로(CAD 를 다시 안 띄움) */ + setLabel: (label: string) => void; + /** 지금 보이는 열 id */ + column: () => string | null; + destroy: () => void; +} + +export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { + let column: string | null = null; + let doc: StructureDoc | null = null; + let preview: DrawingTemplateHandle | null = null; + let seq = 0; + + const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; + + const columnName = el("span", { className: "m02-struct__title" }); + const number = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" }); + const previewHost = el("div", { className: "m02-struct__preview" }); + const infoCard = itemCard( + TEXT.drawingInfo, + el("div", { className: "m02-struct__head", children: [number, columnName, edit] }), + previewHost, + ); + + /** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */ + interface SheetCard { + kind: SheetKind; + root: HTMLElement; + no: HTMLElement; + edit: HTMLButtonElement; + host: HTMLElement; + doc: Workbook | null; + number: string; + handle: SpreadsheetHandle | null; + } + const sheetCard = (kind: SheetKind): SheetCard => { + const no = el("span", { className: "m02-struct__no" }); + const editButton = createButton({ + label: fill(TEXT.editSheet, PART_WORD[kind]), + variant: "ghost", + size: "sm", + }); + const host = el("div", { className: "m02-struct__sheet" }); + editButton.addEventListener("click", () => opts.onEdit(kind)); + return { + kind, + root: itemCard( + PART_WORD[kind], + el("div", { className: "m02-struct__subhead", children: [no, editButton] }), + host, + ), + no, + edit: editButton, + host, + doc: null, + number: "", + handle: null, + }; + }; + const sheets = [sheetCard("area"), sheetCard("material")]; + edit.addEventListener("click", () => opts.onEdit("structure")); + + const root = el("div", { + className: "m02-struct ui-sidebar-panel", + attrs: { hidden: "" }, + children: [infoCard, ...sheets.map((c) => c.root), ...(opts.extraCards ?? [])], + }); + attachCollapsible(root); + + const drop = (): void => { + preview?.destroy(); + preview = null; + for (const c of sheets) { + c.handle?.destroy(); + c.handle = null; + } + }; + + /** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */ + const hideToolbar = (frame: HTMLIFrameElement): void => { + const apply = (): void => { + const inner = frame.contentDocument; + if (!inner?.head || inner.getElementById("m02-struct-hide")) return; + const style = inner.createElement("style"); + style.id = "m02-struct-hide"; + style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; + inner.head.append(style); + }; + frame.addEventListener("load", apply); + apply(); + }; + + /** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 · + * 그 사이 열이 바뀌면 버림 */ + const paintSheet = async (c: SheetCard, mine: number): Promise => { + if (!c.doc) + return void c.host.replaceChildren(itemNote(fill(TEXT.noSheetDoc, PART_WORD[c.kind]))); + try { + const { createSpreadsheet } = await loadSpreadsheet(); + if (mine !== seq || !c.doc) return; + c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false }); + } catch (error) { + if (mine === seq) c.host.replaceChildren(itemNote(fill(TEXT.noSheet, why(error)))); + } + }; + + /** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ + const paintBody = (mine: number): void => { + drop(); + const at = column ?? ""; + number.textContent = doc?.도번 || numberOf("structure", at) || TEXT.noNumber; + edit.hidden = !opts.editable(); + for (const c of sheets) { + c.no.textContent = + c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); + c.edit.hidden = !opts.editable(); + c.host.replaceChildren(); + void paintSheet(c, mine); + } + if (!doc) { + previewHost.replaceChildren(itemNote(TEXT.noDrawing)); + return; + } + previewHost.replaceChildren(); + const source = opts.source(); + // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. + preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: true, + name: `preview:${column ?? ""}`, + layer: source.layer, + projectId: source.projectId, + panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침 + preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 + originMarker: true, // 좌표 0,0 기준점 십자 + noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음 + }); + const frame = previewHost.querySelector("iframe"); + if (frame) hideToolbar(frame); + }; + + async function load(col: { id: string; label: string } | null): Promise { + const mine = ++seq; + drop(); + if (!col) { + root.hidden = true; + column = null; + doc = null; + return; + } + root.hidden = false; + column = col.id; + columnName.textContent = col.label; + number.textContent = numberOf("structure", col.id) || TEXT.noNumber; + doc = null; + previewHost.replaceChildren(); + for (const c of sheets) { + c.doc = null; + c.number = ""; + c.host.replaceChildren(); + } + const source = opts.source(); + const [drawing, ...books] = await Promise.allSettled([ + source.read("structure", col.id), + ...sheets.map((c) => source.read(c.kind, col.id)), + ]); + if (mine !== seq) return; + if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc; + // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 + else if (opts.warnOnFail?.(col.id) ?? true) + showToast(fill(TEXT.readFailed, why(drawing.reason)), "error"); + books.forEach((got, i) => { + if (got.status !== "fulfilled") return; + const c = sheets[i]; + const book = got.value.문서 as Workbook & Record; + c.doc = book; + c.number = String(book?.[PART_FIELD[c.kind]] ?? ""); + }); + paintBody(mine); + } + + return { + root, + load, + setLabel: (label) => { + columnName.textContent = label; + }, + column: () => column, + destroy: () => { + seq += 1; + drop(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts new file mode 100644 index 00000000..c7553432 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts @@ -0,0 +1,29 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemSource.ts + * 항목 카드 · 편집기가 문서를 읽는 **자료 자리**(계획서 25-2d) — 층 고정(`getActiveLayer`)이던 자리를 + * 인자로 뺌. M02 화면은 지금 층(`layerSource`) · 항목 고르기 모달은 프로젝트 복사본 또는 원본 층. + * 카드(`M02_MasterTemplete_ItemCards.ts`)는 어디서 읽는지 모르고 이 창구만 부름. + * ========================================================================== */ + +import { getActiveLayer, readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; + +/** 집계표 열 하나에 딸린 문서 셋 — 구조물 도면 · 면적산출 · 재료산출 */ +export type ItemPart = "structure" | "area" | "material"; + +export interface ItemSource { + /** CAD 자동백업 칸을 가르는 층 — 프로젝트 복사본이면 `project` */ + layer: Layer; + projectId: string | null; + /** 열 id 의 문서 하나 — 못 읽으면 던짐(카드가 「없음」 · 알림으로 갈라 보임) */ + read: (part: ItemPart, column: string) => Promise<{ 문서: unknown }>; +} + +/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */ +export const layerSource = (): ItemSource => { + const layer = getActiveLayer(); + return { + layer, + projectId: null, + read: (part, column) => readTemplate(layer, part, column, null), + }; +}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 45059bc7..1611e592 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -21,9 +21,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; import { openModal } from "@ui/ui_template_modal"; -import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; @@ -34,29 +32,24 @@ import { fetchStructureNumbers, getActiveLayer, readTemplate, - type StructureDoc, type StructureNumbers, } from "./M02_MasterTemplete_Api_Fetch"; import { - mountDrawingTemplate, - type DrawingTemplateDoc, - type DrawingTemplateHandle, -} from "./M02_MasterTemplete_Drawing"; + createItemCards, + PART_FIELD, + PART_WORD, + type EditKind, + type SheetKind, +} from "./M02_MasterTemplete_ItemCards"; +import { layerSource } from "./M02_MasterTemplete_ItemSource"; import "./M02_MasterTemplete_Structure.css"; +export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; + /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ export const STRUCTURE_TABLE = "구조물집계표"; const TEXT = { - drawingInfo: "도면 정보", - edit: "도면 수정", - noNumber: "도번 없음", - noDrawing: "도면 없음", - editSheet: "{value} 수정", - noSheetDoc: "{value} 없음", - noFieldNumber: "{value} 없음", - noSheet: "스프레드시트를 못 띄움 — {value}", - readFailed: "구조물 도면을 못 읽음 — {value}", close: "닫기", find: "찾기", clear: "비움", @@ -74,15 +67,10 @@ const TEXT = { }; /** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */ -export type EditKind = "structure" | "area" | "material"; -export type SheetKind = Exclude; -const PARTS: Record< - EditKind, - { field: keyof StructureNumbers; word: string; copy: (to: string, from: string) => Promise } -> = { - structure: { field: "도번", word: "도면", copy: copyStructure }, - area: { field: "면적번", word: "면적산출", copy: copyArea }, - material: { field: "재료번", word: "재료산출", copy: copyMaterial }, +const COPY: Record Promise> = { + structure: copyStructure, + area: copyArea, + material: copyMaterial, }; /** 집계표 줄 열쇠 → 문서 */ const ROW_KIND: Record = { @@ -93,8 +81,6 @@ const ROW_KIND: Record = { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const fill = (text: string, value: string): string => text.replace("{value}", value); -/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ -const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); /** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; @@ -255,185 +241,30 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane let shown: StructureColumn | null = null; let column: string | null = null; let columnLabel = ""; - let doc: StructureDoc | null = null; - let preview: DrawingTemplateHandle | null = null; - let seq = 0; - const card = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement => - el("section", { - className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", - children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body], - }); - - const columnName = el("span", { className: "m02-struct__title" }); - const number = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" }); - const previewHost = el("div", { className: "m02-struct__preview" }); - const infoCard = card( - TEXT.drawingInfo, - el("div", { className: "m02-struct__head", children: [number, columnName, edit] }), - previewHost, - ); - - /** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */ - interface SheetCard { - kind: SheetKind; - root: HTMLElement; - no: HTMLElement; - edit: HTMLButtonElement; - host: HTMLElement; - doc: Workbook | null; - number: string; - handle: SpreadsheetHandle | null; - } - const sheetCard = (kind: SheetKind): SheetCard => { - const no = el("span", { className: "m02-struct__no" }); - const editButton = createButton({ - label: fill(TEXT.editSheet, PARTS[kind].word), - variant: "ghost", - size: "sm", - }); - const host = el("div", { className: "m02-struct__sheet" }); - editButton.addEventListener("click", () => void openEdit(kind)); - return { - kind, - root: card( - PARTS[kind].word, - el("div", { className: "m02-struct__subhead", children: [no, editButton] }), - host, - ), - no, - edit: editButton, - host, - doc: null, - number: "", - handle: null, - }; - }; - const sheets = [sheetCard("area"), sheetCard("material")]; - - const root = el("div", { - className: "m02-struct ui-sidebar-panel", - attrs: { hidden: "" }, - children: [infoCard, ...sheets.map((c) => c.root)], - }); - attachCollapsible(root); - - const drop = (): void => { - preview?.destroy(); - preview = null; - for (const c of sheets) { - c.handle?.destroy(); - c.handle = null; - } - }; - - const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); - - /** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */ - const hideToolbar = (frame: HTMLIFrameElement): void => { - const apply = (): void => { - const inner = frame.contentDocument; - if (!inner?.head || inner.getElementById("m02-struct-hide")) return; - const style = inner.createElement("style"); - style.id = "m02-struct-hide"; - style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; - inner.head.append(style); - }; - frame.addEventListener("load", apply); - apply(); - }; - - /** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 · - * 그 사이 열이 바뀌면 버림 */ - const paintSheet = async (c: SheetCard, mine: number): Promise => { - if (!c.doc) return void c.host.replaceChildren(note(fill(TEXT.noSheetDoc, PARTS[c.kind].word))); - try { - const { createSpreadsheet } = await loadSpreadsheet(); - if (mine !== seq || !c.doc) return; - c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false }); - } catch (error) { - if (mine === seq) c.host.replaceChildren(note(fill(TEXT.noSheet, why(error)))); - } - }; - - /** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ - const paintBody = (mine: number): void => { - drop(); - number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber; - edit.hidden = !opts.isAdmin; - for (const c of sheets) { - const field = PARTS[c.kind].field; - c.no.textContent = - c.number || numbers[column ?? ""]?.[field] || fill(TEXT.noFieldNumber, field); - c.edit.hidden = !opts.isAdmin; - c.host.replaceChildren(); - void paintSheet(c, mine); - } - if (!doc) { - previewHost.replaceChildren(note(TEXT.noDrawing)); - return; - } - previewHost.replaceChildren(); - // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. - preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { - readOnly: true, - name: `preview:${column ?? ""}`, - layer: getActiveLayer(), - panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침 - preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 - originMarker: true, // 좌표 0,0 기준점 십자 - noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음 - }); - const frame = previewHost.querySelector("iframe"); - if (frame) hideToolbar(frame); - }; - - async function load(col: StructureColumn | null): Promise { - const mine = ++seq; - drop(); - shown = col; - if (!col) { - root.hidden = true; - column = null; - doc = null; - return; - } - root.hidden = false; - column = col.id; - columnLabel = columnHead(col); - columnName.textContent = columnLabel; - number.textContent = numbers[col.id]?.도번 || TEXT.noNumber; - doc = null; - previewHost.replaceChildren(); - for (const c of sheets) { - c.doc = null; - c.number = ""; - c.host.replaceChildren(); - } - const [drawing, ...books] = await Promise.allSettled([ - readTemplate(getActiveLayer(), "structure", col.id, null), - ...sheets.map((c) => readTemplate(getActiveLayer(), c.kind, col.id, null)), - ]); - if (mine !== seq) return; - if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc; + // 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만. + const cards = createItemCards({ + source: layerSource, + editable: () => opts.isAdmin, + onEdit: (kind) => void openEdit(kind), + numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]], // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 - else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error"); - books.forEach((got, i) => { - if (got.status !== "fulfilled") return; - const c = sheets[i]; - const book = got.value.문서 as Workbook & Record; - c.doc = book; - c.number = String(book?.[PARTS[c.kind].field] ?? ""); - }); - paintBody(mine); + warnOnFail: (id) => !!numbers[id], + }); + const root = cards.root; + + function load(col: StructureColumn | null): Promise { + shown = col; + column = col?.id ?? null; + columnLabel = col ? columnHead(col) : ""; + return cards.load(col ? { id: col.id, label: columnLabel } : null); } /** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */ const show = (colId: string | null, columns: StructureColumn[]): void => { const col = columns.find((c) => c.id === colId && isStructureColumn(c)) ?? null; if ((col?.id ?? null) !== column) return void load(col); - if (col) columnName.textContent = columnLabel = columnHead(col); // 머리를 고쳤을 수 있음 + if (col) cards.setLabel((columnLabel = columnHead(col))); // 머리를 고쳤을 수 있음 }; const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { @@ -462,7 +293,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane colId: string, columns: StructureColumn[], ): Promise => { - const { field, word, copy } = PARTS[kind]; + const field = PART_FIELD[kind]; + const word = PART_WORD[kind]; + const copy = COPY[kind]; const to = columns.find((c) => c.id === colId); const toNo = numbers[colId]?.[field]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); @@ -505,12 +338,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const at = column; if (!at) return; // 저장 전 새 열(분류 더하기) — 문서 · 번호가 아직 없음 · 떠나면 집계표 고침까지 버리게 됨 - if (!numbers[at]?.[PARTS[kind].field]) return void showToast(TEXT.saveFirst, "info"); + if (!numbers[at]?.[PART_FIELD[kind]]) return void showToast(TEXT.saveFirst, "info"); if (!(await opts.confirmLeave())) return; opts.onEdit(at, columnLabel, kind); } - edit.addEventListener("click", () => void openEdit("structure")); - return { root, numbers: () => numbers, @@ -524,10 +355,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane show(at, columns); }, pick, - destroy: () => { - seq += 1; - drop(); - root.remove(); - }, + destroy: () => cards.destroy(), }; } diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index fad34b65..fd7e130f 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -21,6 +21,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store ROOT = Path(__file__).resolve().parents[2] M02 = ROOT / "M02_MasterTemplete" PANEL = M02 / "M02_MasterTemplete_Structure.ts" +CARDS = M02 / "M02_MasterTemplete_ItemCards.ts" # 카드 셋 그리기(25-2d 에서 PANEL 에서 옮김) MAIN = M02 / "M02_MasterTemplete_UI_Main.ts" SIDE = M02 / "M02_MasterTemplete_UI_Side.ts" @@ -35,7 +36,7 @@ def test_집계표_이름이_서버와_같음(): def test_소스가_700줄을_넘지_않음(): - for path in (PANEL, MAIN, M02 / "M02_MasterTemplete_Structure.css"): + for path in (PANEL, CARDS, MAIN, M02 / "M02_MasterTemplete_Structure.css"): assert len(_text(path).splitlines()) <= 700, path.name @@ -53,8 +54,8 @@ def test_소스가_700줄을_넘지_않음(): # (`{열 id: {도번, 면적번, 재료번}}`) ("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE), ("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE), - ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL), - ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], PANEL), + ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], CARDS), + ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], CARDS), ], ) def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str], target: Path): @@ -132,7 +133,7 @@ def test_열_머리_글_잇기(): def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶(): """특성 · 도면층 패널을 숨기지 않음 — 곁값 panelsCollapsed 로 접어 실음(B07 은 안 보냄).""" - panel = _text(PANEL) + panel = _text(CARDS) assert ".cad-inspector" not in panel and "panelsCollapsed: true" in panel drawing = _text(M02 / "M02_MasterTemplete_Drawing.ts") assert "panelsCollapsed: options.panelsCollapsed" in drawing From 0d8f96726260cb750e82d957a18712fb82aff8ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:26:23 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(M02):=20=EC=A7=91=EA=B3=84=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EC=99=84=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=E2=80=94=20=EC=9E=85=EB=A0=A5=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=C2=B7=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=EC=9E=90=EC=9E=AC=20=C2=B7=20=EB=8B=A8=EA=B0=80(?= =?UTF-8?q?=EB=8B=AB=ED=9E=8C=20=EB=AC=B6=EC=9D=8C)=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A8=EB=82=A0=20=C2=B7=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=ED=8C=90=20=C2=B7=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EA=B8=B8=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=EB=A1=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=AF=B8=EB=A6=AC=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=C2=B7=20=EB=B3=B5=EC=82=AC=EB=B3=B8?= =?UTF-8?q?=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=82=B0=20(PLAN=2025-2?= =?UTF-8?q?b=20=C2=B7=2025-2e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스냅샷 나눔: 원본 모으기(M02_Item_Source) · 맞추기(M02_Item_Snapshot) · 복사본 읽기 · 쓰기(M02_Item_Copy) · 단가 묶음 · 계산(M02_Item_Prices) - 단가.json = 담은 로직 · 하위 로직 · 요소 · 준용 · 찾기 표 · 재료 고르기 후보 · 지역 · 계약종별 후보(마스터 파일 차례 그대로) - 길 /api/projects/{id}/items(목록 · 미리 가져오기) · /{폴더}/{종류}(GET · PUT 판 409 · 면적 · 재료 서버 계산값) · /refresh · /calc - 고친 복사본은 구조물에서 빠져도 안 지움 · 옛 복사본은 읽을 때 채움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/M02_Item_Copy.py | 259 ++++++++++++++ M02_MasterTemplete/M02_Item_Prices.py | 249 +++++++++++++ M02_MasterTemplete/M02_Item_Snapshot.py | 200 +++++------ M02_MasterTemplete/M02_Item_Source.py | 124 +++++++ .../M02_Project_Items_Router.py | 113 ++++++ main.py | 3 + resources/tester/test_item_copy_complete.py | 335 ++++++++++++++++++ 7 files changed, 1174 insertions(+), 109 deletions(-) create mode 100644 M02_MasterTemplete/M02_Item_Copy.py create mode 100644 M02_MasterTemplete/M02_Item_Prices.py create mode 100644 M02_MasterTemplete/M02_Item_Source.py create mode 100644 M02_MasterTemplete/M02_Project_Items_Router.py create mode 100644 resources/tester/test_item_copy_complete.py diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py new file mode 100644 index 00000000..4117b2c5 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -0,0 +1,259 @@ +"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b). + +복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 — +쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움. +옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음). +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +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_Item_Prices as prices +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Item_Snapshot import ( + MANIFEST_NAME, + PICKS_FILE, + VALUES_FILE, + build_snapshot, + complete_old, + items_dir, + now_text, + write_snapshot, +) +from M02_MasterTemplete.M02_Item_Source import ( + LABOR_FILE, + LINK_FILE, + PRICE_FILE, + build_source, + read_json, + version_of, +) + +#: 길의 종류 이름 → 복사본 파일 +KINDS = { + "도면": "도면.json", + "면적": "면적.json", + "재료": "재료.json", + "자재조합": "자재조합.json", + "입력연결": LINK_FILE, + "입력값": VALUES_FILE, + "고른자재": PICKS_FILE, +} +#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출) +SKELETON = {"도면": "structure", "면적": "area", "재료": "material"} +SEATS = ("면적", "재료", "선택", None) +_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$") +_MT = re.compile(r"^MT\d{6}$") + + +class Stale(ValueError): + def __init__(self, current: str): + super().__init__("다른 곳에서 먼저 고쳤습니다 — 다시 읽고 고치세요.") + self.current = current + + +def folder_of(project_root: str | Path, folder: str) -> Path: + """복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404).""" + if not _FOLDER.match(folder): + raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」") + target = items_dir(project_root) / folder + if not (target / MANIFEST_NAME).is_file(): + raise LookupError(f"복사본이 없음 「{folder}」") + complete_old(target) + return target + + +def source_changed(manifest: dict[str, Any]) -> bool | None: + """지금 원본(층 · 마스터)을 다시 모은 판이 가져온 판과 다른가 — 원본이 없어졌으면 None.""" + try: + source = build_source(manifest["원본"]) + except (LookupError, ValueError, KeyError): + return None + return version_of(source) != manifest.get("원본판") + + +def list_items(project_root: str | Path) -> list[dict[str, Any]]: + base = items_dir(project_root) + out = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + if not child.is_dir() or not _FOLDER.match(child.name): + continue + if not (child / MANIFEST_NAME).is_file(): + continue + complete_old(child) + manifest = read_json(child / MANIFEST_NAME) or {} + origin = manifest.get("원본") or {} + out.append( + { + "폴더": child.name, + "이름": origin.get("name"), + "원본": origin, + "가져온날": manifest.get("가져온날"), + "마스터바뀜": source_changed(manifest), + "구조물": manifest.get("구조물") or [], + "고침": bool(manifest.get("고침")), + } + ) + return out + + +def read_file(project_root: str | Path, folder: str, kind: str) -> dict[str, Any]: + target = folder_of(project_root, folder) + content = read_json(target / KINDS[kind]) + return {"종류": kind, "판": version_of(content), "내용": content} + + +# ── 모양 검사 ─────────────────────────────────────────────────────── + + +def _check_values(content: Any) -> None: + if not isinstance(content, dict): + raise ValueError("입력값은 {로직키: {입력이름: {값, 확인}}} 이어야 합니다.") + for logic, inputs in content.items(): + if not isinstance(inputs, dict): + raise ValueError(f"입력값 「{logic}」 은 {{입력이름: {{값, 확인}}}} 이어야 합니다.") + for name, one in inputs.items(): + if not isinstance(one, dict) or set(one) - {"값", "확인"}: + raise ValueError(f"입력값 「{logic}|{name}」 은 {{값, 확인}} 이어야 합니다.") + if not isinstance(one.get("확인", False), bool): + raise ValueError(f"입력값 「{logic}|{name}」 확인은 참 · 거짓이어야 합니다.") + if isinstance(one.get("값"), (dict, list)): + raise ValueError(f"입력값 「{logic}|{name}」 값은 수 · 글 · 빈 값이어야 합니다.") + + +def _check_picks(content: Any) -> None: + if not isinstance(content, dict): + raise ValueError("고른자재는 {로직키: {호표 차례: MT키}} 이어야 합니다.") + for logic, picks in content.items(): + if not isinstance(picks, dict): + raise ValueError(f"고른자재 「{logic}」 은 {{호표 차례: MT키}} 이어야 합니다.") + for at, key in picks.items(): + if not str(at).isdigit() or not isinstance(key, str) or not _MT.match(key): + raise ValueError(f"고른자재 「{logic}」 {at} → 「{key}」 — 차례는 수 · 값은 MT 키") + + +def _check_links(content: Any) -> None: + rows = content.get("줄") if isinstance(content, dict) else None + if not isinstance(rows, list): + raise ValueError("입력연결은 {판, 줄: [...]} 이어야 합니다.") + for row in rows: + if not isinstance(row, dict) or not row.get("로직") or not row.get("입력"): + raise ValueError("입력연결 줄은 {로직, 입력, 자리, 알약?} 이어야 합니다.") + if row.get("자리") not in SEATS: + raise ValueError(f"입력연결 자리 「{row.get('자리')}」 — 면적 · 재료 · 선택 · 빈 값") + + +def check(kind: str, content: Any) -> None: + if kind == "입력값": + _check_values(content) + elif kind == "고른자재": + _check_picks(content) + elif kind == "입력연결": + _check_links(content) + elif kind in SKELETON: + layers.check_skeleton(SKELETON[kind], content) + elif not isinstance(content, dict): + raise ValueError(f"{kind} 은 JSON 객체여야 합니다.") + + +def write_file( + project_root: str | Path, + folder: str, + kind: str, + version: str | None, + content: Any, + recalc: Callable[[dict], dict] | None = None, +) -> dict[str, Any]: + """판이 같을 때만 씀 — 면적 · 재료는 `recalc`(서버 계산값)를 거친 것을 씀.""" + target = folder_of(project_root, folder) + check(kind, content) + current = version_of(read_json(target / KINDS[kind])) + if version != current: + raise Stale(current) + if recalc is not None and kind in ("면적", "재료"): + content = recalc(content) + atomic_write_json(target / KINDS[kind], content) + manifest = read_json(target / MANIFEST_NAME) or {} + atomic_write_json(target / MANIFEST_NAME, {**manifest, "고침": True, "고친날": now_text()}) + return {"종류": kind, "판": version_of(content)} + + +# ── 새로 가져오기 · 미리 가져오기 ───────────────────────────────────── + + +def _kept_values(values: Any, labor: dict[str, Any]) -> dict[str, Any]: + """같은 로직 · 같은 입력 이름만 살림.""" + names = { + str(row.get("키")): {spec["이름"] for spec in row.get("입력", [])} + for row in labor.get("로직", []) + } + out: dict[str, Any] = {} + for logic, inputs in (values or {}).items(): + kept = {k: v for k, v in (inputs or {}).items() if k in names.get(logic, set())} + if kept: + out[logic] = kept + return out + + +def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]: + """같은 로직 · 있는 호표 차례만 살림.""" + lines = {str(row.get("키")): len(row.get("호표", [])) for row in labor.get("로직", [])} + out: dict[str, Any] = {} + for logic, chosen in (picks or {}).items(): + kept = {k: v for k, v in (chosen or {}).items() if int(k) < lines.get(logic, 0)} + if kept: + out[logic] = kept + return out + + +def refresh(project_root: str | Path, folder: str) -> dict[str, Any]: + """층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림.""" + target = folder_of(project_root, folder) + manifest = read_json(target / MANIFEST_NAME) or {} + values = read_json(target / VALUES_FILE) + picks = read_json(target / PICKS_FILE) + files = build_snapshot(manifest["원본"]) + labor = files.get(LABOR_FILE) or {} + files[VALUES_FILE] = _kept_values(values, labor) + files[PICKS_FILE] = _kept_picks(picks, labor) + for old in target.glob("*.json"): # 원본에서 빠진 파일(도면 없앰 등)은 지움 + if old.name != MANIFEST_NAME and old.name not in files: + old.unlink() + for name, content in files.items(): + atomic_write_json(target / name, content) + source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)} + stamp = now_text() + fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp} + atomic_write_json(target / MANIFEST_NAME, fresh) + return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]} + + +def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]: + """구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음).""" + folder = snapshot_folder_name(item) + target = items_dir(project_root) / folder + manifest = read_json(target / MANIFEST_NAME) + made = not isinstance(manifest, dict) + if made: + manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()}) + return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")} + + +def calc(project_root: str | Path, folder: str) -> dict[str, Any]: + """복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음.""" + target = folder_of(project_root, folder) + labor = read_json(target / LABOR_FILE) + if not isinstance(labor, dict): + raise LookupError(f"일위대가 조합이 없는 항목 「{folder}」 — 계산할 것이 없음") + return prices.calc( + labor, + read_json(target / PRICE_FILE) or {}, + read_json(target / VALUES_FILE) or {}, + read_json(target / PICKS_FILE) or {}, + ) diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py new file mode 100644 index 00000000..9db65771 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -0,0 +1,249 @@ +"""집계 항목 복사본의 단가 묶음 `단가.json` · 복사본만으로 계산 (PLAN 25-1b · 25-2b · 25-2e). + +단가 묶음 = 그 항목 일위대가 조합이 담은 로직을 계산할 때 닿을 수 있는 마스터 줄 전부(닫힌 묶음): + - 담은 로직 · 로직이 부르는 하위 로직(`로직(GC…)`) 끝까지 + - 식이 가리키는 요소(인력 · 기계 · 재료 · 유가전력 · 요율 · 환율 …) · 준용 대상 + - 찾기 표(`찾기(QC…)` · `찾기(QF…)` 소요량 · 계수) + - 재료 고르기 조건의 후보 자재품목 줄 · 품셈재료 연결 · 지역 · 계약종별 후보 +원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 · +자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게. + +`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가 +그대로 읽는 꼴. +계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +mf = m01.cm.mf +mk = m01.mk +mcb = m01.mcb +mm = mf.mp.mm # 품셈재료 · 자재품목 후보(`master_material`) + +#: 틀을 못 풀었을 때 통째로 담지 않는 테이블 — 자재품목(3만 줄) +_TOO_BIG = ("MT",) + + +def master() -> tuple[dict[str, dict], Any]: + """(마스터 파일 묶음, 색인) — 읽기 전용(시험은 `m01.FOLDER` 를 사본으로 바꿈).""" + return m01.loaded() + + +def _texts(row: dict) -> list[str]: + out = [step["식"] for step in row.get("중간", [])] + if "결과" in row: + out.append(row["결과"]["식"]) + for item in row.get("호표", []): + out.append(item["수량"]) + if not isinstance(item.get("요소"), dict): + out.append(item["요소"]) + out += [extra["식"] for extra in row.get("덧줄", [])] + return out + + +class _Collector: + def __init__(self, whole: Any): + self.whole = whole + self.keys: set[str] = set() + self.logics: set[str] = set() + self.missing: list[str] = [] + + def refs(self, row: dict, template: str) -> list[str]: + """원문번호 틀 → 닿을 수 있는 요소 참조 — 고르기로 끼우고, 못 풀면 그 테이블 전부.""" + if "{" not in template: + return [template] + expanded = mf._expand(row, template, self.whole) + if expanded is not None: + return expanded + tid = mk.id_of(template) + if not tid or tid in _TOO_BIG: + self.missing.append(f"{row.get('키')} 「{template}」 틀을 못 풂") + return [] + return list(self.whole.index.get(tid, {})) + + def add(self, ref: str) -> None: + try: + row = self.whole.get(ref) + except mf.FormulaError: + return # 없는 요소 — 계산이 같은 까닭으로 멈춤(마스터와 같게) + key = str(row.get("키")) + if key in self.keys: + return + self.keys.add(key) + if row.get("준용") not in (None, ""): + self.add(str(row["준용"])) + link = row.get("연결") if key[:2] == mf.LINKED else None + if isinstance(link, str): + self.add(link) + elif isinstance(link, dict): + self.widely(link, row.get("단위")) + + def widely(self, cond: dict, unit: Any) -> None: + """지역 · 계약종별 「입력」 조건 — 입력값과 상관없이 닿을 수 있는 후보 전부.""" + want = mm.plain(cond.get("이름")) + if cond.get("계약종별") == "입력": + for key, one in self.whole.index.get(mm.PRICED, {}).items(): + if one.get("구분") == "전력" and mm.plain(one.get("이름")).startswith(want): + self.keys.add(key) + return + spec, unit_p = mm.plain_spec(cond.get("규격")), mm.plain_unit(unit) + for root, _, s, u, key in mm._market_index(self.whole): + if root.endswith(want) and u == unit_p and (not spec or s == spec): + self.keys.add(key) + + def pick(self, row: dict, cond: dict, unit: Any) -> None: + """재료 고르기 조건 — 대표 줄 · 후보 줄(`{입력}` 은 고르기 값으로 끼움).""" + if cond.get("지역") == "입력" or cond.get("계약종별") == "입력": + self.widely(cond, unit) + return + holes = sorted( + {h for v in cond.values() if isinstance(v, str) for h in mf._HOLE.findall(v)} + ) + variants = [cond] + if holes: + env_sets = mf._expand(row, "|".join(f"{{{h}}}" for h in holes), self.whole) + if env_sets is None: + variants = [mf.static(cond)] + else: + variants = [] + for filled in env_sets: + env = dict(zip(holes, filled.split("|"))) + variants.append( + {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in cond.items()} + ) + for one in variants: + if one.get("대표"): + self.add(str(one["대표"])) + if not one.get("구분") and not one.get("검색어"): + continue + for key in mf.candidates(self.whole, one, None, None): + self.add(key) + + def logic(self, key: str) -> None: + if key in self.logics: + return + try: + row = self.whole.logic(key) + except mf.FormulaError as error: + self.missing.append(f"{key} · {error}") + return + self.logics.add(key) + self.keys.add(key) + for text in _texts(row): + try: + node = mf.parse(text) + except mf.FormulaError: + continue # 못 읽는 식 — 계산도 같은 까닭으로 멈춤 + for part in mf.walk(node): + kind = part[0] + if kind == "ref" or kind == "find": + for ref in self.refs(row, part[1]): + self.add(ref) + elif kind == "logic": + for ref in self.refs(row, part[1]): + self.logic(ref) + for item in row.get("호표", []): + if isinstance(item.get("요소"), dict): + self.pick(row, item["요소"], item.get("단위")) + + +def collect(logic_keys: list[str]) -> dict[str, Any]: + """담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" + files, whole = master() + collector = _Collector(whole) + for key in logic_keys: + collector.logic(key) + out: dict[str, Any] = {} + for name, data in files.items(): + slot = m01.items_key(data) + rows = [row for row in data.get(slot) or [] if str(row.get("키")) in collector.keys] + if rows: + out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows} + if collector.missing: + out["_빠진"] = sorted(set(collector.missing)) + return out + + +def to_json(value: Any) -> Any: + """Decimal 을 JSON 수로 — 정수는 int · 아니면 원래 자릿수 그대로(float 로 흔들지 않음).""" + return json.loads(json.dumps(value, ensure_ascii=False, default=_number)) + + +def _number(value: Any) -> Any: + if isinstance(value, Decimal): + text = str(value) # 「14480.0」 은 소수 그대로 — 끝수 · 표기가 마스터와 같게 + return int(value) if "." not in text and "E" not in text.upper() else float(value) + raise TypeError(type(value).__name__) + + +def load_prices(prices: dict[str, Any]) -> Any: + """`단가.json` → 계산 색인(수는 Decimal · 마스터 적재와 같게).""" + raw = json.dumps({k: v for k, v in prices.items() if not k.startswith("_")}, ensure_ascii=False) + files = json.loads(raw, parse_float=Decimal, parse_int=Decimal) + return mf.Master(files) + + +# ── 계산(25-2e) ───────────────────────────────────────────────────── + + +def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]: + """담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`.""" + out = [] + for key in mcb.logic_keys(labor.get("줄") or {}): + try: + row = snapshot.logic(key) + except mf.FormulaError: + continue + mine = values.get(key) or {} + for spec in row.get("입력", []): + got = mine.get(spec["이름"]) + if not isinstance(got, dict) or got.get("값") in (None, ""): + out.append({"로직": key, "입력": spec["이름"]}) + return out + + +def calc( + labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any] +) -> dict[str, Any]: + """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합.""" + snapshot = load_prices(prices) + gaps = missing_inputs(snapshot, labor, values) + blocked = {gap["로직"] for gap in gaps} + sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) + lines, stuck = [], [] + for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1): + head: dict[str, Any] = {"차례": at, "로직": key} + if key in blocked: + names = [gap["입력"] for gap in gaps if gap["로직"] == key] + lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"}) + stuck.append(key) + continue + given = {name: one.get("값") for name, one in (values.get(key) or {}).items()} + try: + with mf.mp.choosing((picks or {}).get(key)): + got = mf.run(snapshot, key, given) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error: + lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__}) + stuck.append(key) + continue + if "결과" in got: + lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]}) + continue + for name in mf.COST_ITEMS: + sums[name] += got[name] + lines.append( + { + **head, + "ok": True, + "갈래": "돈", + **{k: got[k] for k in mf.COST_ITEMS}, + "계": got["계"], + } + ) + return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}) diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 1c35e542..cd583500 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -1,19 +1,19 @@ -"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3). +"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3 · 25-2b). [저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: - 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사 - (`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM · - `자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`) + (`M02_Item_Source.build_source` — 집계 · 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 · + 단가) + 프로젝트 값 `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · + `manifest.json` - 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트 - 값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로 - - 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) -원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것. + 값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로([새로 가져오기]는 사용자가) + - 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) — 단 프로젝트에서 고친 + 흔적(`manifest.고침`)이 있으면 남김 +원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가 · 단가는 M01 시스템 것. """ from __future__ import annotations -import hashlib -import json import logging import shutil from datetime import datetime @@ -25,97 +25,54 @@ from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pip 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 +from M02_MasterTemplete.M02_Item_Source import ( + LINK_FILE, + PRICE_FILE, + build_source, + read_json, + version_of, +) 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")) +VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}} +PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}} 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 now_text() -> str: + return datetime.now().isoformat(timespec="seconds") 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 + """원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" + return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}} + + +def write_snapshot(target: Path, item: dict[str, Any], users: list[str], extra: dict) -> dict: + """폴더에 새로 씀 — manifest 는 마지막(있으면 다 쓴 폴더 · 끊기면 다음 저장이 다시 씀).""" + files = build_snapshot(item) + target.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + atomic_write_json(target / name, content) + source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)} + stamp = now_text() + manifest = { + "원본": item, + "판": version_of(source), + "원본판": version_of(source), + "적용일": stamp, + "가져온날": stamp, + "구조물": users, + **extra, + } + atomic_write_json(target / MANIFEST_NAME, manifest) + return manifest def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]: @@ -134,11 +91,6 @@ def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], lis 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) @@ -147,37 +99,30 @@ def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: failed: list[str] = [] for folder, (item, users) in sorted(wanted.items()): target = base / folder - manifest = _read(target / MANIFEST_NAME) + manifest = read_json(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) + write_snapshot(target, item, users, {}) 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) + if not child.is_dir() or child.name in wanted: + continue + manifest = read_json(child / MANIFEST_NAME) + if isinstance(manifest, dict) and manifest.get("고침"): + if manifest.get("구조물"): # 고친 복사본은 남기되 쓰는 구조물은 비움 + atomic_write_json(child / MANIFEST_NAME, {**manifest, "구조물": []}) + continue + shutil.rmtree(child) + removed.append(child.name) return {"만듦": made, "지움": removed, "못만듦": failed} @@ -191,7 +136,44 @@ def safe_sync(project_root: str | Path) -> None: logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root) +def complete_old(folder: Path) -> bool: + """옛 스냅샷(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것. + + 옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날). + """ + manifest = read_json(folder / MANIFEST_NAME) + if not isinstance(manifest, dict): + return False + lacking = [ + name + for name in (LINK_FILE, PRICE_FILE, VALUES_FILE, PICKS_FILE) + if not (folder / name).is_file() + ] + if not lacking and "원본판" in manifest: + return False + source = ( + build_source(manifest["원본"]) if (LINK_FILE in lacking or PRICE_FILE in lacking) else {} + ) + for name in lacking: + atomic_write_json(folder / name, source.get(name, {})) + kept = { + name: read_json(folder / name) + for name in sorted(p.name for p in folder.glob("*.json")) + if name not in (MANIFEST_NAME, VALUES_FILE, PICKS_FILE) + } + atomic_write_json( + folder / MANIFEST_NAME, + { + **manifest, + "가져온날": manifest.get("가져온날") or manifest.get("적용일"), + "원본판": manifest.get("원본판") or version_of(kept), + "옮겨받음": now_text(), + }, + ) + return True + + 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") + data = read_json(items_dir(project_root) / snapshot_folder_name(item) / "집계.json") return data if isinstance(data, dict) else None diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py new file mode 100644 index 00000000..e5c45c27 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -0,0 +1,124 @@ +"""집계 항목 원본 — 층(시스템 · 회사 · 개인)에서 항목 내용을 모음 (PLAN 21-1 · 25-1b · 25-2b). + +모으는 것 = `{파일 이름: 내용}`: + `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM · + `자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) · + `입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) · + `단가.json` 계산에 닿는 마스터 줄 전부 +`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. +프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. +원본 층 자리 = `M02_Template_Layers` 경로 · 일위대가 · 단가는 M01 시스템 것. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Template_Layers as layers + +TABLE_NAME = "구조물집계표" +UM_FILE = "자재조합.json" +UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`) +INPUTS_DIRNAME = "inputs" # 층의 항목 연결 파일 자리(25-1b · sub4 층 자료 틀) +#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) +PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) +LINK_FILE = "입력연결.json" +PRICE_FILE = "단가.json" +LABOR_FILE = "일위대가.json" + + +def layer_root(item: dict[str, Any]) -> Path: + """항목 원본 층 뿌리 — 시스템 · 회사 · 개인.""" + 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_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def row_of(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 version_of(content: Any) -> str: + raw = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8") + return hashlib.sha256(raw).hexdigest()[:16] + + +def labor_rows(ua_key: str) -> dict[str, Any] | None: + """UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`).""" + from M01_MasterData import M01_MasterData_Store as m01 + + ua = row_of(read_json(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_json(Path(m01.FOLDER) / file) + row = row_of(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 input_links(root: Path, column_id: str) -> dict[str, Any]: + """층의 항목 연결 `inputs/<열id>.json` — 없으면 빈 연결(같은 모양).""" + found = read_json(root / INPUTS_DIRNAME / f"{column_id}.json") if column_id else None + if isinstance(found, dict) and isinstance(found.get("줄"), list): + return found + return {"판": None, "줄": []} + + +def build_source(item: dict[str, Any]) -> dict[str, Any]: + """원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError.""" + from M02_MasterTemplete import M02_Item_Prices as prices + + root = layer_root(item) + row = row_of(read_json(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_json(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_json(root / kind / f"{column_id}.json") if column_id else None + if part is not None: + files[name] = part + if row.get("자재조합"): + um = row_of(read_json(root / UM_FILE), str(row["자재조합"])) + if um is not None: + files[UM_FILE] = um + labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None + if labor is not None: + files[LABOR_FILE] = labor + files[LINK_FILE] = input_links(root, column_id) + logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] + files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys)) if logic_keys else {} + return files diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py new file mode 100644 index 00000000..0b183cae --- /dev/null +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -0,0 +1,113 @@ +"""프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e). + + GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜) + POST …/items {summary_item} 미리 가져오기(구조물에 안 이은 항목) + GET …/items/{폴더}/{종류} {종류, 판, 내용} + PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값 + POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림) + POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) +종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재. +⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util import common_util_spreadsheet as spreadsheet +from common_util.common_util_auth import verify_session +from common_util.common_util_summary_item import parse_summary_item +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes + +router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) + +Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재"] + + +class WriteBody(BaseModel): + 판: str | None = None + 내용: Any + + +class PrefetchBody(BaseModel): + summary_item: dict[str, Any] + + +async def _root(session: dict[str, Any], project_id: str): + return (await layer_routes._project(session, project_id))["root"] + + +async def _run(fn, *args): + try: + return await asyncio.to_thread(fn, *args) + except copies.Stale as error: + raise HTTPException( + status_code=409, detail={"message": str(error), "판": error.current} + ) from error + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@router.get("/{project_id}/items") +async def list_items( + project_id: str, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + return {"항목": await _run(copies.list_items, await _root(session, project_id))} + + +@router.post("/{project_id}/items") +async def prefetch_item( + project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + try: + item = parse_summary_item(body.summary_item) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return await _run(copies.prefetch, await _root(session, project_id), item) + + +@router.get("/{project_id}/items/{folder}/{kind}") +async def read_item_file( + project_id: str, folder: str, kind: Kind, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + return await _run(copies.read_file, await _root(session, project_id), folder, kind) + + +def _recalc(document: dict) -> dict: + try: + return spreadsheet.with_server_values(document) + except spreadsheet.RecalcError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + + +@router.put("/{project_id}/items/{folder}/{kind}") +async def write_item_file( + project_id: str, + folder: str, + kind: Kind, + body: WriteBody, + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + root = await _root(session, project_id) + return await _run(copies.write_file, root, folder, kind, body.판, body.내용, _recalc) + + +@router.post("/{project_id}/items/{folder}/refresh") +async def refresh_item( + project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + return await _run(copies.refresh, await _root(session, project_id), folder) + + +@router.post("/{project_id}/items/{folder}/calc") +async def calc_item( + project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + return await _run(copies.calc, await _root(session, project_id), folder) diff --git a/main.py b/main.py index 2513076b..bee37211 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_te from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router +from M02_MasterTemplete.M02_Project_Items_Router import router as m02_project_items_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -641,6 +642,8 @@ app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) +# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. +app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py new file mode 100644 index 00000000..3367b7b8 --- /dev/null +++ b/resources/tester/test_item_copy_complete.py @@ -0,0 +1,335 @@ +"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본). + +- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판 +- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값 +- 마스터 단가 한 줄을 바꿔도 프로젝트 계산 그대로 · 목록에 「마스터 바뀜」 · 새로 가져오기 뒤 바뀜 + (입력값 · 고른자재는 같은 키면 살림) +- 쓰기 뒤 다시 읽기 같음 · 판 다르면 409 · 모양 틀리면 400 · 고친 복사본은 안 지움 +- 옛 복사본은 읽을 때 채움 · 미리 가져오기 · 남의 프로젝트 403 +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes +from M02_MasterTemplete import M02_Project_Items_Router as items_router +from M02_MasterTemplete import M02_Template_Layers as layers + +REAL = config_system.PROJECT_ROOT / "resources/master_template" +MASTER = config_system.PROJECT_ROOT / "resources/master_data" +PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} +FOLDER = "system_none_SS000011" +LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀 + + +@pytest.fixture(scope="module") +def master_copy(tmp_path_factory) -> Path: + target = tmp_path_factory.mktemp("master_data") + for path in MASTER.glob("*.json"): + shutil.copy2(path, target / path.name) + return target + + +@pytest.fixture +def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: + system = tmp_path / "master" + system.mkdir() + for name in (layers.COMBO_FILE, "자재조합.json"): + shutil.copy2(REAL / name, system / name) + for kind in ("table", "structure", "area", "material"): + shutil.copytree(REAL / kind, system / kind) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(m01, "FOLDER", master_copy) + 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, "root": root, "master": master_copy} + + +def _save(root: Path, pipes: list[dict]) -> dict[str, list[str]]: + (root / "B05_Profile/route/structures.json").write_text( + json.dumps({"revision": 1, "structures": []}), encoding="utf-8" + ) + (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( + json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8" + ) + return snap.sync_snapshots(root) + + +def _pipe(summary: dict | None = PIPE) -> dict[str, Any]: + return {"chainage_m": 40.0, "source": "user", "summary_item": summary} + + +def _read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _auto_values(root: Path) -> dict[str, Any]: + """담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게.""" + labor = _read(root / "templates/items" / FOLDER / "일위대가.json") + _, whole = m01.loaded() + out: dict[str, Any] = {} + for row in labor["로직"]: + values = m01.mau.values(whole, whole.logic(row["키"])) + out[row["키"]] = {k: {"값": prices.to_json(v), "확인": True} for k, v in values.items()} + return out + + +def _fill_values(root: Path) -> dict[str, Any]: + current = copies.read_file(root, FOLDER, "입력값") + values = _auto_values(root) + copies.write_file(root, FOLDER, "입력값", current["판"], values) + return values + + +# ── 새 파일 ────────────────────────────────────────── + + +def test_지정하면_새_파일까지_복사(world): + root = world["root"] + assert _save(root, [_pipe()])["만듦"] == [FOLDER] + folder = root / "templates/items" / FOLDER + assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음 + assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {} + price = _read(folder / "단가.json") + rows = {k: len(v.get("줄") or v.get("표")) for k, v in price.items()} + assert rows["인력.json"] >= 2 and rows["재료_자재품목.json"] > 0 and "_빠진" not in price + keys = {row["키"] for data in price.values() for row in data.get("줄") or data.get("표")} + assert {"GF000176", "GC000088", "GF000113", "GC000267", LABOR_PRICE} <= keys + manifest = _read(folder / "manifest.json") + assert manifest["가져온날"] and len(manifest["원본판"]) == 16 + assert copies.list_items(root) == [ + { + "폴더": FOLDER, + "이름": "관공 관매설", + "원본": PIPE, + "가져온날": manifest["가져온날"], + "마스터바뀜": False, + "구조물": ["pipe@40.0"], + "고침": False, + } + ] + + +def test_층_입력연결을_복사(world): + root, system = world["root"], world["system"] + (system / "inputs").mkdir() + link = {"판": "a1", "줄": [{"로직": "GF000176", "입력": "관경", "자리": "면적", "알약": "x"}]} + column = next( + r["열"] for r in _read(system / layers.COMBO_FILE)["줄"] if r["키"] == PIPE["key"] + ) + (system / "inputs" / f"{column}.json").write_text(json.dumps(link, ensure_ascii=False), "utf-8") + _save(root, [_pipe()]) + assert copies.read_file(root, FOLDER, "입력연결")["내용"] == link + + +def test_단가_묶음은_닫혀_있음(world): + """조합마다 복사본 단가 묶음만으로 돌린 값 = 마스터로 돌린 값(자동값 · 고르기 끝값).""" + files, whole = prices.master() + combos = [r for d in files.values() if d.get("그룹") == "일위대가조합" for r in d["줄"]] + runs = 0 + for ua in combos: + keys = prices.mcb.logic_keys(ua) + snapshot = prices.load_prices(prices.to_json(prices.collect(keys))) + for key in keys: + try: + row = whole.logic(key) + except prices.mf.FormulaError: + continue + for pick in (0, -1): + values = m01.mau.values(whole, row) + for spec in row.get("입력", []): + if spec.get("고르기"): + values[spec["이름"]] = spec["고르기"][pick] + assert _run(whole, key, values) == _run(snapshot, key, values), (ua["키"], key) + runs += 1 + assert len(combos) >= 30 and runs >= 300 + + +def _run(master: Any, key: str, values: dict) -> str: + try: + got = prices.mf.run(master, key, dict(values)) + except Exception as error: # 까닭도 같아야 함 + return f"멈춤 {error}" + return str(got.get("계", got.get("결과"))) + + +# ── 계산 · 마스터 바뀜 · 새로 가져오기 ─────────────────── + + +def test_입력이_없으면_빠진_입력_목록(world): + root = world["root"] + _save(root, [_pipe()]) + got = copies.calc(root, FOLDER) + assert got["계"] == 0 and len(got["멈춤"]) == 3 + assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"] + + +def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world): + root, master_dir = world["root"], world["master"] + _save(root, [_pipe()]) + values = _fill_values(root) + before = copies.calc(root, FOLDER) + assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0 + files, whole = m01.loaded() + ua = next( + r + for d in files.values() + if d.get("그룹") == "일위대가조합" + for r in d["줄"] + if r["키"] == "UA000036" + ) + given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()} + expected = prices.to_json(prices.mcb.run(whole, ua, given)) + assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0 + labor_file = master_dir / "인력.json" + original = labor_file.read_bytes() + try: + data = _read(labor_file) + row = next(r for r in data["줄"] if r["키"] == LABOR_PRICE) + row["값"] = row["값"] * 2 + labor_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + assert copies.calc(root, FOLDER) == before # 복사본만 씀 + assert copies.list_items(root)[0]["마스터바뀜"] is True + copies.refresh(root, FOLDER) + after = copies.calc(root, FOLDER) + assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"] + assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림 + assert copies.list_items(root)[0]["마스터바뀜"] is False + finally: + labor_file.write_bytes(original) + + +def test_새로_가져오면_없는_키는_버림(world): + root = world["root"] + _save(root, [_pipe()]) + folder = root / "templates/items" / FOLDER + (folder / "입력값.json").write_text( + json.dumps( + { + "GF000176": {"관경": {"값": 600, "확인": True}, "옛입력": {"값": 1}}, + "GX999999": {"a": {"값": 1}}, + }, + ensure_ascii=False, + ), + "utf-8", + ) + (folder / "고른자재.json").write_text( + json.dumps({"GF000176": {"0": "MT000001", "99": "MT000002"}}), "utf-8" + ) + copies.refresh(root, FOLDER) + assert _read(folder / "입력값.json") == {"GF000176": {"관경": {"값": 600, "확인": True}}} + assert _read(folder / "고른자재.json") == {"GF000176": {"0": "MT000001"}} + + +# ── 쓰기 · 판 · 모양 · 지우기 규칙 ───────────────────────── + + +def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world): + root = world["root"] + _save(root, [_pipe()]) + first = copies.read_file(root, FOLDER, "고른자재") + wrote = copies.write_file( + root, FOLDER, "고른자재", first["판"], {"GF000176": {"0": "MT000010"}} + ) + again = copies.read_file(root, FOLDER, "고른자재") + assert again["내용"] == {"GF000176": {"0": "MT000010"}} and again["판"] == wrote["판"] + with pytest.raises(copies.Stale): + copies.write_file(root, FOLDER, "고른자재", first["판"], {}) + for kind, bad in ( + ("입력값", {"GF000176": {"관경": 600}}), + ("입력값", {"GF000176": {"관경": {"값": 1, "확인": "네"}}}), + ("고른자재", {"GF000176": {"첫": "MT000001"}}), + ("입력연결", {"줄": [{"로직": "GF000176", "입력": "관경", "자리": "어딘가"}]}), + ("도면", {}), + ): + with pytest.raises(ValueError): + copies.write_file(root, FOLDER, kind, again["판"], bad) + with pytest.raises(LookupError): + copies.read_file(root, "../../etc", "입력값") + + +def test_고친_복사본은_구조물에서_빠져도_안_지움(world): + root = world["root"] + _save(root, [_pipe()]) + assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙) + _save(root, [_pipe()]) + current = copies.read_file(root, FOLDER, "입력값") + copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}}) + assert _save(root, [_pipe(None)])["지움"] == [] + assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"] + + +def test_미리_가져오기(world): + root = world["root"] + _save(root, []) + made = copies.prefetch(root, PIPE) + assert made["폴더"] == FOLDER and made["만듦"] is True + assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로 + assert copies.list_items(root)[0]["구조물"] == [] + assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움 + + +def test_옛_복사본은_읽을_때_채움(world): + root = world["root"] + _save(root, [_pipe()]) + folder = root / "templates/items" / FOLDER + for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"): + (folder / name).unlink() + manifest = _read(folder / "manifest.json") + for key in ("원본판", "가져온날"): + manifest.pop(key) + (folder / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8") + assert copies.read_file(root, FOLDER, "입력값")["내용"] == {} + filled = _read(folder / "manifest.json") + assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"] + assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"] + assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본 + + +# ── 길 · 권한 ──────────────────────────────────────── + + +def test_길_권한_남의_프로젝트는_403(world, monkeypatch): + root = world["root"] + _save(root, [_pipe()]) + + async def fake_row(project_id: str) -> dict[str, Any]: + return {"company_id": 3, "storage_path": str(root)} + + monkeypatch.setattr(layer_routes, "_project_row", fake_row) + monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path) + app = FastAPI() + app.include_router(items_router.router) + session = {"role": "USER", "company_id": 3} + app.dependency_overrides[verify_session] = lambda: session + client = TestClient(app) + base = "/api/projects/p1/items" + assert client.get(base).json()["항목"][0]["폴더"] == FOLDER + got = client.get(f"{base}/{FOLDER}/입력값").json() + put = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"], "내용": {}}) + assert put.status_code == 200 + stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}}) + assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"] + assert client.get(f"{base}/{FOLDER}/모름").status_code == 422 + assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"] + assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400 + session["company_id"] = 4 + assert client.get(base).status_code == 403 + assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403 From 083c12ad3aa15d27112cf24c2fcdcbbc0b615e38 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:31:05 +0900 Subject: [PATCH 5/9] =?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=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=EB=84=B7=20=C2=B7?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=ED=8E=B8=EC=A7=91=20=C2=B7=20=EB=92=A4=EB=A1=9C=20?= =?UTF-8?q?(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오른쪽 상세를 카드 넷(도면 · 면적산출 · 재료산출 · 선택 입력)으로 — M02 와 같은 카드 한 벌 - 카드 [수정] = 모달 안 편집 화면(CAD · 스프레드시트 · 선택 입력 값 넣기) · [뒤로] = 선택지 - 고침은 프로젝트 복사본만 · 안 가져온 항목은 「프로젝트로 가져와 고침」 · 마스터가 바뀜 표시 + [새로 가져오기] - 서버 길(sub6 · sub4)이 없으면 약속 모양의 가짜 응답(이음 자리 FAKE) - B05 구조물 폼이 projectId 를 모달에 넘김 · 모달 넓힘 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- A00_Common/design/design_shared.ts | 2 +- B05_Profile/B05_Profile_Api_Structures.ts | 2 + .../B05_Profile_UI_Structures_Panel.ts | 6 +- .../M02_MasterTemplete_ItemCards.ts | 19 ++ ui_template/ui_template_item_picker.css | 125 +++++++- ui_template/ui_template_item_picker.ts | 121 ++++---- ui_template/ui_template_item_picker_detail.ts | 264 ++++++++++++++++ ui_template/ui_template_item_picker_edit.ts | 229 ++++++++++++++ .../ui_template_item_picker_project.ts | 284 ++++++++++++++++++ 9 files changed, 977 insertions(+), 75 deletions(-) create mode 100644 ui_template/ui_template_item_picker_detail.ts create mode 100644 ui_template/ui_template_item_picker_edit.ts create mode 100644 ui_template/ui_template_item_picker_project.ts diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 77815407..80770b88 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -186,7 +186,7 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection { onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM), onReveal: () => shared.revealLeft(), }, - { includeDetail: () => shared.view() === "cross" }, + { includeDetail: () => shared.view() === "cross", projectId: shared.projectId }, ); shared.parts.structures = shared.structures; return shared.structures; diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index f025a255..98248d1c 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -53,6 +53,8 @@ export interface StructureOptionField { export interface StructuresSectionOptions { /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ includeDetail?: boolean | (() => boolean); + /** 집계 항목 고르기 모달이 프로젝트 복사본을 고치려면 필요 — 없으면 원본 보기만(25-2d) */ + projectId?: string | null; } /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7d98beeb..d38f48ae 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -497,7 +497,11 @@ export function createStructuresSection( summary.bind({ pick: () => { // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 - void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => { + void openItemPicker({ + current: summary.get(), + canCopy: true, + projectId: sectionOptions.projectId ?? null, + }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); commitSummary(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 394742f8..820ee158 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -82,6 +82,8 @@ export interface ItemCardsHandle { load: (col: { id: string; label: string } | null) => Promise; /** 이름만 새로(CAD 를 다시 안 띄움) */ setLabel: (label: string) => void; + /** [수정] 단추 글 — 종류마다 · `null` 은 기본 글 */ + relabel: (labels: Partial>) => void; /** 지금 보이는 열 id */ column: () => string | null; destroy: () => void; @@ -142,6 +144,16 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }; const sheets = [sheetCard("area"), sheetCard("material")]; edit.addEventListener("click", () => opts.onEdit("structure")); + const buttons: Record = { + structure: edit, + area: sheets[0].edit, + material: sheets[1].edit, + }; + const defaults: Record = { + structure: TEXT.edit, + area: fill(TEXT.editSheet, PART_WORD.area), + material: fill(TEXT.editSheet, PART_WORD.material), + }; const root = el("div", { className: "m02-struct ui-sidebar-panel", @@ -267,6 +279,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { setLabel: (label) => { columnName.textContent = label; }, + relabel: (labels) => { + for (const kind of Object.keys(buttons) as EditKind[]) { + const button = buttons[kind]; + (button.querySelector(".ui-btn__label") ?? button).textContent = + labels[kind] ?? defaults[kind]; + } + }, column: () => column, destroy: () => { seq += 1; diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index fa14a514..508023a2 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -1,15 +1,16 @@ /* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */ .ui-item-picker { - width: min(900px, 94vw); + width: min(1320px, 96vw); + max-height: 94vh; } .ui-item-picker .ui-modal__body { - height: min(60vh, 520px); + height: min(80vh, 800px); } .ui-item-picker__cols { display: grid; - grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr); + grid-template-columns: minmax(220px, 3fr) minmax(320px, 9fr); gap: var(--spacing-12); flex: 1; min-height: 0; @@ -47,6 +48,115 @@ overflow: auto; } +.ui-item-picker__cols[hidden], +.ui-item-picker__actions[hidden], +.ui-item-picker__edit-host[hidden] { + display: none; +} + +.ui-item-picker__head { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* 프로젝트 복사본 상태 줄 — 가져온 날 · 마스터가 바뀜 · [새로 가져오기] */ +.ui-item-picker__status { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-item-picker__changed { + color: var(--color-warning); +} + +.ui-item-picker__fake { + padding: 0 var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +/* 카드 넷 — M02 와 같은 카드 · 칸이 좁으면 한 줄씩(카드 하나가 300px 안 되면) */ +.ui-item-picker__cards { + display: flex; + min-width: 0; +} + +.ui-item-picker__cards .m02-struct { + flex: 1 1 0; + padding-top: 0; + border-top: 0; + min-width: 0; +} + +/* 편집 화면 — 뒤로 · 제목 · 저장 한 줄 + 편집 부품이 남는 높이를 채움 */ +.ui-item-picker__edit-host { + display: flex; + flex: 1; + min-height: 0; +} + +.ui-item-picker__edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + flex: 1; + min-width: 0; + min-height: 0; +} + +.ui-item-picker__edit-top { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} + +.ui-item-picker__edit-title { + flex: 1 1 12rem; + margin: 0; + color: var(--color-text-heading); + font-size: var(--text-body); + overflow-wrap: anywhere; +} + +.ui-item-picker__edit-note { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-item-picker__edit-body { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.ui-item-picker__edit-sheet, +.ui-item-picker__edit-cad { + flex: 1; + min-width: 0; + min-height: 0; +} + +.ui-item-picker__edit-inputs { + flex: 1; + min-width: 0; + padding: var(--spacing-8); + overflow: auto; +} + +.ui-item-picker__edit-inputs .m02-struct__card { + border: 0; +} + .ui-item-picker__name { margin: 0; font-size: var(--text-body); @@ -104,3 +214,12 @@ justify-content: flex-end; gap: var(--spacing-8); } + +/* 좁은 폭 — 나무는 위(낮게) · 상세는 아래로 쌓고 카드는 한 줄씩 */ +@media (max-width: 760px) { + .ui-item-picker__cols { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); + overflow: auto; + } +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index aef2bf73..5baec0eb 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -13,17 +13,12 @@ import { createButton, createInputField, el, showToast } from "./ui_template_ele import { buildTree, filterRows, - folderLines, isCurrent, - itemName, layerOf, ownerOf, pathIds, rowId, - rowTip, - shown, toSummary, - type FolderKind, type PickNode, type PickerLayer, type PickerRow, @@ -33,6 +28,8 @@ import { import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; +import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; +import { createEditView, type EditView } from "./ui_template_item_picker_edit"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -41,6 +38,8 @@ export interface ItemPickerOptions { current: SummaryItem | null; /** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */ canCopy: boolean; + /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ + projectId?: string | null; } export type ItemPickerResult = @@ -48,14 +47,6 @@ export type ItemPickerResult = 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"), @@ -110,7 +101,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise openEdit(request), + }); + const cols = el("div", { + className: "ui-item-picker__cols", + children: [ + el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), + detailView.root, + ], + }); + const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } }); + let editView: EditView | null = null; 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" }); @@ -126,47 +132,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { - if (!chosen) { - detail.replaceChildren(hint(t("M02_PickerHint"))); - } else { - const where = [labels().layers[layerOf(chosen)], shown(chosen.종류), shown(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: itemName(chosen), - attrs: { title: rowTip(chosen, labels()) }, - }), - small(where), - ...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []), - el("ul", { - className: "ui-item-picker__folder", - children: folderLines(chosen).map((line) => - el("li", { - attrs: line.name && line.key ? { title: line.key } : {}, - children: [ - el("span", { - className: "ui-item-picker__folder-label", - text: FOLDER_LABEL[line.kind](), - }), - // 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」 - el("span", { - className: line.name - ? "" - : line.key - ? "ui-item-picker__key" - : "ui-item-picker__none", - text: line.name || line.key || t("M02_PickerNone"), - }), - ], - }), - ), - }), - ); - } + detailView.show(chosen); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -268,29 +234,44 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + // 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음 + beforeClose: async () => { + if (editView && !(await editView.leave())) return false; + editView?.destroy(); 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], - }), - ); + body.append(cols, editHost, actions); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts new file mode 100644 index 00000000..f7f1099e --- /dev/null +++ b/ui_template/ui_template_item_picker_detail.ts @@ -0,0 +1,264 @@ +/* ============================================================================= + * ui_template_item_picker_detail.ts + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · + * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀 + * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * + * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). + * [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집). + * 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `ui_template_item_picker_edit.ts`. + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; +import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; +import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; +import type { + InputsCardHandle, + LinkDoc, + ValueDoc, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { + folderLines, + itemName, + layerOf, + rowTip, + shown, + type PickerLayer, + type PickerRow, +} from "./ui_template_item_picker_data"; +import { + fakeLink, + folderOf, + loadInputs, + PART_OF, + projectItems, + type ProjectItemInfo, + type ProjectItems, +} from "./ui_template_item_picker_project"; + +/** 편집 화면을 여는 데 필요한 것 — 「선택 입력」 도 한 종류 */ +export interface EditRequest { + kind: EditKind | "inputs"; + row: PickerRow; + info: ProjectItemInfo; + items: ProjectItems; + projectId: string; +} + +export interface ItemDetailOptions { + /** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */ + projectId: string | null; + layerLabels: Record; + hint: string; + unit: string; + onEdit: (request: EditRequest) => void; +} + +export interface ItemDetail { + root: HTMLElement; + show: (row: PickerRow | null) => void; + /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ + reload: () => void; +} + +const TEXT = { + importEdit: "프로젝트로 가져와 고침", + notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음", + imported: "프로젝트에 가져옴 {value}", + changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로", + refresh: "새로 가져오기", + refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?", + importFailed: "프로젝트로 가져오지 못함 — {value}", + refreshFailed: "새로 가져오지 못함 — {value}", + listFailed: "프로젝트 항목을 못 읽음 — {value}", + fake: "임시 저장(서버 길 준비 중)", + loading: "읽는 중…", + noCopy: "복사본에 없음", + combo: "자재조합", + price: "일위대가", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const fill = (text: string, value: string): string => text.replace("{value}", value); +const dateOf = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); + +export function createItemDetail(opts: ItemDetailOptions): ItemDetail { + const root = el("div", { className: "ui-item-picker__detail" }); + const head = el("div", { className: "ui-item-picker__head" }); + const status = el("div", { className: "ui-item-picker__status", attrs: { hidden: "" } }); + const cardsHost = el("div", { className: "ui-item-picker__cards" }); + root.append(head, status, cardsHost); + + let current: PickerRow | null = null; + let seq = 0; + let infos: ProjectItemInfo[] | null = null; + let cards: ItemCardsHandle | null = null; + let inputs: InputsCardHandle | null = null; + let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; + let info: ProjectItemInfo | null = null; + const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; + + const small = (text: string): HTMLElement => + el("p", { className: "ui-item-picker__where", text }); + + const paintHead = (row: PickerRow): void => { + const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)] + .filter(Boolean) + .join(" › "); + const pairs = folderLines(row).filter( + (line) => line.kind === "combo" || line.kind === "unitPrice", + ); + head.replaceChildren( + el("h4", { + className: "ui-item-picker__name", + text: itemName(row), + attrs: { title: rowTip(row, { layers: opts.layerLabels, unclassified: "" }) }, + }), + small(where), + ...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []), + ...pairs + .filter((line) => line.name || line.key) + .map((line) => + small(`${line.kind === "combo" ? TEXT.combo : TEXT.price} ${line.name || line.key}`), + ), + ); + }; + + /** 프로젝트 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ + const makeSource = (row: PickerRow): ItemSource => + info && opts.projectId + ? { + layer: "project", + projectId: opts.projectId, + read: async (part) => { + const doc = await items?.read(info!.폴더, PART_OF[part]); + if (doc == null) throw new Error(TEXT.noCopy); + return { 문서: doc }; + }, + } + : { + layer: layerOf(row), + projectId: null, + read: (part, column) => readTemplate(layerOf(row), part, column, null), + }; + + const paintStatus = async (row: PickerRow, mine: number): Promise => { + if (!items) return void (status.hidden = true); + const nodes: (HTMLElement | string)[] = []; + if (!info) nodes.push(TEXT.notImported); + else { + nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); + if (items.isFake()) + nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake })); + const changed = await items.masterChanged(row, info).catch(() => false); + if (mine !== seq) return; + if (changed) { + const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); + button.addEventListener("click", () => void refresh(row)); + nodes.push( + el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }), + button, + ); + } + } + status.replaceChildren(...nodes); + status.hidden = false; + }; + + async function refresh(row: PickerRow): Promise { + if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; + try { + await items.refresh(row, info); + infos = null; + show(row); + } catch (error) { + showToast(fill(TEXT.refreshFailed, why(error)), "error"); + } + } + + /** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */ + async function edit(kind: EditKind | "inputs"): Promise { + const row = current; + if (!row || !items || !opts.projectId) return; + if (!info) { + try { + info = await items.importItem(row); + infos = null; + } catch (error) { + showToast(fill(TEXT.importFailed, why(error)), "error"); + return; + } + } + opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); + } + + async function paintInputs(row: PickerRow, mine: number): Promise { + if (!inputs) return; + try { + const { list, fake } = await loadInputs(row); + let link: LinkDoc | null = fake ? fakeLink(list) : null; + let values: ValueDoc = {}; + if (info && items) { + link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link; + values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + } + if (mine === seq) inputs.set({ list, link, values }); + } catch (error) { + if (mine === seq) inputs.set(null, why(error)); + } + } + + async function ensureCards(): Promise { + if (cards && inputs) return; + const [cardModule, inputModule] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), + import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + ]); + inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") }); + cards = cardModule.createItemCards({ + source: () => source, + editable: () => !!items, + onEdit: (kind) => void edit(kind), + warnOnFail: () => false, + extraCards: [inputs.root], + }); + cardsHost.replaceChildren(cards.root); + } + + function show(row: PickerRow | null): void { + const mine = ++seq; + current = row; + if (!row) { + head.replaceChildren(el("p", { className: "ui-item-picker__hint", text: opts.hint })); + status.hidden = true; + cardsHost.replaceChildren(); + cards = null; + inputs = null; + return; + } + paintHead(row); + void (async () => { + try { + if (items) infos ??= await items.list(); + } catch (error) { + if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); + infos = []; + } + if (mine !== seq) return; + info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null; + source = makeSource(row); + await ensureCards(); + if (mine !== seq || !cards || !inputs) return; + const label = info ? null : TEXT.importEdit; + cards.relabel({ structure: label, area: label, material: label }); + inputs.setEditLabel(label ?? "선택 입력 수정"); + cards.root.hidden = false; + void paintStatus(row, mine); + void cards.load({ id: row.열, label: itemName(row) }); + void paintInputs(row, mine); + })(); + } + + return { root, show, reload: () => show(current) }; +} diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts new file mode 100644 index 00000000..e9a89b1e --- /dev/null +++ b/ui_template/ui_template_item_picker_edit.ts @@ -0,0 +1,229 @@ +/* ============================================================================= + * ui_template_item_picker_edit.ts + * 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜. + * 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드. + * 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜. + * [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음. + * 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임). + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; +import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; +import type { + InputsCardHandle, + LinkDoc, + ValueDoc, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { EditRequest } from "./ui_template_item_picker_detail"; +import { itemName } from "./ui_template_item_picker_data"; +import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project"; + +export interface EditView { + root: HTMLElement; + /** 붙인 뒤 부름 — 편집 부품을 받아 세움 */ + start: () => void; + isDirty: () => boolean; + /** 저장 안 한 고침을 버릴지 묻고, 괜찮으면 부품을 치움 — false 면 그대로 */ + leave: () => Promise; + destroy: () => void; +} + +const TEXT = { + back: "← 뒤로", + save: "저장", + saved: "프로젝트 복사본에 저장했음", + saveFailed: "저장하지 못함 — {value}", + loadFailed: "복사본을 못 읽음 — {value}", + discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", + outOfRange: "범위 밖 값이 있어 저장하지 못함", + noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸", + drawing: "도면", + area: "면적산출", + material: "재료산출", + inputs: "선택 입력", + suffix: "{value} 수정", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export function createEditView(req: EditRequest, onBack: () => void): EditView { + const { kind, row, info, items } = req; + let dirty = false; + let saving = false; + let doc: unknown = null; + let drawing: DrawingTemplateHandle | null = null; + let sheet: SpreadsheetHandle | null = null; + let card: InputsCardHandle | null = null; + + const word = { + structure: TEXT.drawing, + area: TEXT.area, + material: TEXT.material, + inputs: TEXT.inputs, + }[kind]; + const back = createButton({ label: TEXT.back, variant: "ghost", size: "sm" }); + const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" }); + save.disabled = true; + const note = el("span", { className: "ui-item-picker__edit-note" }); + const body = el("div", { className: "ui-item-picker__edit-body" }); + const root = el("div", { + className: "ui-item-picker__edit", + children: [ + el("div", { + className: "ui-item-picker__edit-top", + children: [ + back, + el("h4", { + className: "ui-item-picker__edit-title", + text: `${itemName(row)} — ${fill(TEXT.suffix, word)}`, + }), + note, + save, + ], + }), + body, + ], + }); + + const setDirty = (next: boolean): void => { + dirty = next; + save.disabled = !next || saving; + }; + const fail = (text: string): void => { + body.replaceChildren(el("p", { className: "ui-item-picker__hint", text })); + }; + + /** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */ + async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> { + const { list, fake } = await loadInputs(row); + const link = + ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? + (fake ? fakeLink(list) : null); + const 항목 = (link?.줄 ?? []) + .filter((one) => one.자리 === "면적") + .map((one) => ({ + 키: one.알약 ?? `입력|${one.로직}|${one.입력}`, + 이름: one.입력, + 단위: + list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력) + ?.단위 ?? "", + })); + return 항목.length + ? { links: { 항목, 모드: "입력" } } + : { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill }; + } + + async function mountSheet(kindOf: "area" | "material"): Promise { + const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + kindOf === "area" + ? areaLinks() + : Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }), + import("../M01_MasterData/M01_MasterData_Api_Fetch"), + ]); + const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials }; + if (pills.notice) note.textContent = pills.notice; + const host = el("div", { className: "ui-item-picker__edit-sheet" }); + body.replaceChildren(host); + sheet = createSpreadsheet(host, doc as Workbook, { + readOnly: false, + tabs: false, + onChange: () => setDirty(true), + links, + }); + } + + async function mountDrawing(): Promise { + const { mountDrawingTemplate } = + await import("../M02_MasterTemplete/M02_MasterTemplete_Drawing"); + const host = el("div", { className: "ui-item-picker__edit-cad" }); + body.replaceChildren(host); + drawing = mountDrawingTemplate(host, (doc as StructureDoc).도면 as never, { + onSave: () => void doSave(), + readOnly: false, + name: `item:${info.폴더}`, + layer: "project", + projectId: req.projectId, + onChanged: (changed) => setDirty(changed), + originMarker: true, + noArea: true, + }); + } + + async function mountInputs(): Promise { + const [{ createInputsCard }, loaded] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + loadInputs(row), + ]); + const link = + ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? + (loaded.fake ? fakeLink(loaded.list) : null); + const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + card = createInputsCard({ mode: "value", onChange: () => setDirty(true) }); + card.set({ list: loaded.list, link, values }); + body.replaceChildren( + el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), + ); + } + + async function start(): Promise { + try { + if (kind !== "inputs") { + doc = await items.read(info.폴더, PART_OF[kind]); + if (doc == null) return fail(fill(TEXT.loadFailed, "복사본에 없음")); + } + if (kind === "structure") await mountDrawing(); + else if (kind === "inputs") await mountInputs(); + else await mountSheet(kind); + } catch (error) { + fail(fill(TEXT.loadFailed, why(error))); + } + } + + async function doSave(): Promise { + if (saving) return; + saving = true; + save.disabled = true; + try { + if (kind === "inputs") { + if (!card?.isValid()) return void showToast(TEXT.outOfRange, "error"); + await items.write(info.폴더, "입력값", card.getValues()); + } else if (kind === "structure") { + const edited = await drawing?.getDoc(); + await items.write(info.폴더, "도면", { ...(doc as StructureDoc), 도면: edited }); + } else if (sheet) { + // 번호 칸은 스프레드시트가 모름 — 읽은 문서 위에 편집본을 얹음(M02 저장과 같은 규칙) + const merged = { ...(doc as Record), ...sheet.getDoc() }; + await items.write(info.폴더, PART_OF[kind], merged); + doc = merged; + } + setDirty(false); + showToast(TEXT.saved, "success"); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); + } finally { + saving = false; + save.disabled = !dirty; + } + } + + const destroy = (): void => { + drawing?.destroy(); + sheet?.destroy(); + drawing = null; + sheet = null; + root.remove(); + }; + const leave = async (): Promise => !dirty || showConfirmDialog(TEXT.discard); + + save.addEventListener("click", () => void doSave()); + back.addEventListener("click", async () => { + if (await leave()) onBack(); + }); + + return { root, start: () => void start(), isDirty: () => dirty, leave, destroy }; +} diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts new file mode 100644 index 00000000..4ad1da45 --- /dev/null +++ b/ui_template/ui_template_item_picker_project.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * ui_template_item_picker_project.ts + * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름. + * + * 프로젝트 복사본 GET /api/projects/{id}/items 목록 + * GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속) + * POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조) + * POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기 + * 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a) + * + * ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함: + * 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 · + * 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값). + * 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { + InputsList, + LinkDoc, + LogicInput, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; + +/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ +export type ProjectPart = + "도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재"; + +/** 원본 종류 → 복사본 부분 */ +export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = { + structure: "도면", + area: "면적", + material: "재료", +}; + +export interface ProjectItemInfo { + 폴더: string; + 원본: { layer: string; owner: string | null; key: string; name?: string }; + 원본판?: string; + 가져온날?: string; + /** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */ + 마스터바뀜?: boolean; +} + +/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ +export const folderOf = (row: PickerRow): string => + `${layerOf(row)}_${(ownerOf(row) ?? "none").replace("/", "-")}_${row.key}`; + +interface Reply { + status: number; + body: unknown; +} + +const enc = encodeURIComponent; +const reasonOf = (reply: Reply): string => { + const detail = (reply.body as { detail?: unknown } | null)?.detail; + return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`; +}; + +async function call(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + ...init, + headers: init.body ? { "Content-Type": "application/json" } : undefined, + }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */ +const routeMissing = (reply: Reply): boolean => + reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found"; + +const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); + +/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */ +interface FakeItem { + info: ProjectItemInfo; + parts: Partial>; +} +type FakeDb = Record; +const memory = new Map(); +const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`; + +function fakeLoad(projectId: string): FakeDb { + const kept = memory.get(projectId); + if (kept) return kept; + try { + const raw = localStorage.getItem(storeKey(projectId)); + const db = raw ? (JSON.parse(raw) as FakeDb) : {}; + memory.set(projectId, db); + return db; + } catch { + const db: FakeDb = {}; + memory.set(projectId, db); + return db; + } +} + +function fakeSave(projectId: string): void { + try { + localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId))); + } catch { + // 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨 + } +} + +/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */ +async function readOriginals( + row: PickerRow, +): Promise<{ parts: FakeItem["parts"]; version: string }> { + const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"]; + const got = await Promise.all( + kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)), + ); + const parts: FakeItem["parts"] = {}; + kinds.forEach((kind, index) => { + if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서); + }); + return { parts, version: got.map((one) => one?.판 ?? "").join("|") }; +} + +export interface ProjectItems { + /** 프로젝트에 가져온 항목 목록 */ + list: () => Promise; + /** 복사본 부분 하나 — 없으면 null */ + read: (folder: string, part: ProjectPart) => Promise; + /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */ + write: (folder: string, part: ProjectPart, doc: unknown) => Promise; + /** 프로젝트로 미리 가져옴 */ + importItem: (row: PickerRow) => Promise; + /** 새로 가져옴 — 복사본을 원본으로 덮음 */ + refresh: (row: PickerRow, info: ProjectItemInfo) => Promise; + /** 가져온 뒤 원본이 바뀌었나 */ + masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise; + /** 가짜로 동작 중인가(서버 길이 아직 없음) */ + isFake: () => boolean; +} + +const clients = new Map(); + +/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */ +export function projectItems(projectId: string): ProjectItems { + const kept = clients.get(projectId); + if (kept) return kept; + let fake = false; + const base = `/projects/${enc(projectId)}/items`; + + const client: ProjectItems = { + isFake: () => fake, + list: async () => { + if (!fake) { + const reply = await call(base); + if (reply.status === 200 && Array.isArray(reply.body)) + return reply.body as ProjectItemInfo[]; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + fake = true; // FAKE — 서버 길이 아직 없음 + } + return Object.values(fakeLoad(projectId)).map((item) => item.info); + }, + read: async (folder, part) => { + if (!fake) { + const reply = await call(`${base}/${enc(folder)}/${enc(part)}`); + if (reply.status === 200) return reply.body; + if (reply.status === 404) return null; + throw new Error(reasonOf(reply)); + } + return fakeLoad(projectId)[folder]?.parts[part] ?? null; + }, + write: async (folder, part, doc) => { + if (!fake) { + const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, { + method: "PUT", + body: JSON.stringify(doc), + }); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + return; + } + const item = fakeLoad(projectId)[folder]; + if (!item) throw new Error("프로젝트에 가져오지 않은 항목"); + item.parts[part] = structuredClone(doc); + fakeSave(projectId); + }, + importItem: async (row) => { + const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; + if (!fake) { + const reply = await call(base, send(ref)); + if (reply.status < 300) return reply.body as ProjectItemInfo; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + fake = true; // FAKE + } + const { parts, version } = await readOriginals(row); + const info: ProjectItemInfo = { + 폴더: folderOf(row), + 원본: { ...ref, name: row.종류 }, + 원본판: version, + 가져온날: new Date().toISOString(), + 마스터바뀜: false, + }; + fakeLoad(projectId)[info.폴더] = { info, parts }; + fakeSave(projectId); + return info; + }, + refresh: async (row, info) => { + if (!fake) { + const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({})); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + return reply.body as ProjectItemInfo; + } + delete fakeLoad(projectId)[info.폴더]; + return client.importItem(row); + }, + masterChanged: async (row, info) => { + if (!fake) return info.마스터바뀜 === true; + return (await readOriginals(row)).version !== (info.원본판 ?? ""); + }, + }; + clients.set(projectId, client); + return client; +} + +/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */ +const inputsCache = new Map>(); + +async function fakeInputs(row: PickerRow): Promise { + const combo = row.일위대가 ?? ""; + const got = await call(`/m01/combo?key=${enc(combo)}`); + const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null) + ?.combo; + if (got.status !== 200 || !header) throw new Error(reasonOf(got)); + const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] }; + for (const one of header.담은로직 ?? []) { + const [logic, auto] = await Promise.all([ + call(`/m01/logic?key=${enc(one.로직)}`), + call(`/m01/logic/auto?key=${enc(one.로직)}`), + ]); + const row = ( + logic.body as { + logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] }; + } | null + )?.logic; + if (logic.status !== 200 || !row) { + list.빠진로직.push(one.로직); + continue; + } + const values = (auto.body as { 값?: Record } | null)?.값 ?? {}; + list.로직.push({ + 로직: one.로직, + 이름: row.이름 ?? one.로직, + 결과단위: row.결과단위 ?? null, + 입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })), + }); + } + return list; +} + +/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */ +export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> { + const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; + const kept = inputsCache.get(id); + if (kept) return kept; + const made = (async () => { + const owner = ownerOf(row); + const reply = await call( + `/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`, + ); + if (reply.status === 200) return { list: reply.body as InputsList, fake: false }; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + return { list: await fakeInputs(row), fake: true }; // FAKE + })(); + inputsCache.set(id, made); + made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시 + return made; +} + +/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */ +export function fakeLink(list: InputsList): LinkDoc { + return { + 판: "fake", + 줄: list.로직.flatMap((logic) => + logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), + ), + }; +} From b038e99d8d84150d109f954b09af4bab71273d03 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:31:44 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(M02):=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EA=B8=B8=20=EB=AA=A8=EC=96=91=20=EB=A7=9E=EC=B6=A4=20=E2=80=94?= =?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=B8=20{layer,=20owner,=20key}(=EC=9D=B4=EB=A6=84=EC=9D=80?= =?UTF-8?q?=20=EC=B8=B5=20SS=20=EC=A4=84)=20=C2=B7=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=9B=90=EB=B3=B8=ED=8C=90=20=C2=B7=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=97=B0=EA=B2=B0=EC=9D=80=20sub4=20=EC=B8=B5=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0(=5FInputs.read=5Ffile)=EB=A1=9C=20(PLAN=2025?= =?UTF-8?q?-2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/M02_Item_Copy.py | 5 ++++- M02_MasterTemplete/M02_Item_Source.py | 20 +++++++++++++++--- .../M02_Project_Items_Router.py | 21 +++++++++++++++---- resources/tester/test_item_copy_complete.py | 7 +++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index 4117b2c5..f7e3cd8c 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -89,13 +89,16 @@ def list_items(project_root: str | Path) -> list[dict[str, Any]]: complete_old(child) manifest = read_json(child / MANIFEST_NAME) or {} origin = manifest.get("원본") or {} + changed = source_changed(manifest) out.append( { "폴더": child.name, "이름": origin.get("name"), "원본": origin, + "원본판": manifest.get("원본판"), "가져온날": manifest.get("가져온날"), - "마스터바뀜": source_changed(manifest), + "마스터바뀜": bool(changed), + "원본없음": changed is None, "구조물": manifest.get("구조물") or [], "고침": bool(manifest.get("고침")), } diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index e5c45c27..134c62ce 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -22,7 +22,6 @@ from M02_MasterTemplete import M02_Template_Layers as layers TABLE_NAME = "구조물집계표" UM_FILE = "자재조합.json" UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`) -INPUTS_DIRNAME = "inputs" # 층의 항목 연결 파일 자리(25-1b · sub4 층 자료 틀) #: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) LINK_FILE = "입력연결.json" @@ -55,6 +54,15 @@ def row_of(document: Any, key: str) -> dict[str, Any] | None: return None +def item_name(item: dict[str, Any]) -> str: + """층 SS 줄의 보이는 이름(종류 · 분류1~3) — 없으면 LookupError.""" + row = row_of(read_json(layer_root(item) / layers.COMBO_FILE), str(item.get("key"))) + if row is None: + raise LookupError(f"집계 항목 {item.get('key')} 이 {item.get('layer')} 층에 없음") + parts = (row.get(k) for k in ("종류", "분류1", "분류2", "분류3")) + return " ".join(str(p) for p in parts if p) or str(row["키"]) + + def version_of(content: Any) -> str: raw = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8") return hashlib.sha256(raw).hexdigest()[:16] @@ -88,8 +96,14 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: def input_links(root: Path, column_id: str) -> dict[str, Any]: - """층의 항목 연결 `inputs/<열id>.json` — 없으면 빈 연결(같은 모양).""" - found = read_json(root / INPUTS_DIRNAME / f"{column_id}.json") if column_id else None + """층의 항목 연결 `inputs/<열id>.json`(sub4 `_Inputs.read_file`) · 없으면 빈 연결.""" + from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + if not column_id: + return {"판": None, "줄": []} + with store.at(root): + found = inputs.read_file(column_id) if isinstance(found, dict) and isinstance(found.get("줄"), list): return found return {"판": None, "줄": []} diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 0b183cae..4ca0e2f7 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -1,7 +1,7 @@ """프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e). GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜) - POST …/items {summary_item} 미리 가져오기(구조물에 안 이은 항목) + POST …/items {layer, owner, key} 미리 가져오기(구조물에 안 이은 항목) → {폴더, 만듦, 가져온날} GET …/items/{폴더}/{종류} {종류, 판, 내용} PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값 POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림) @@ -23,6 +23,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_summary_item import parse_summary_item from M02_MasterTemplete import M02_Item_Copy as copies from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes +from M02_MasterTemplete.M02_Item_Source import item_name router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) @@ -35,7 +36,13 @@ class WriteBody(BaseModel): class PrefetchBody(BaseModel): - summary_item: dict[str, Any] + """`{layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음.""" + + layer: str | None = None + owner: str | None = None + key: str | None = None + name: str | None = None + summary_item: dict[str, Any] | None = None async def _root(session: dict[str, Any], project_id: str): @@ -66,10 +73,16 @@ async def list_items( async def prefetch_item( project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session) ) -> dict[str, Any]: - try: - item = parse_summary_item(body.summary_item) + raw = body.summary_item or {"layer": body.layer, "owner": body.owner, "key": body.key} + try: # 모양 먼저(이름은 자리만) — 그다음 층 SS 줄에서 이름 + item = parse_summary_item({**raw, "name": raw.get("name") or body.name or "-"}) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error + if not (raw.get("name") or body.name): + try: + item["name"] = item_name(item) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error return await _run(copies.prefetch, await _root(session, project_id), item) diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 3367b7b8..1546f92c 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -119,8 +119,10 @@ def test_지정하면_새_파일까지_복사(world): "폴더": FOLDER, "이름": "관공 관매설", "원본": PIPE, + "원본판": manifest["원본판"], "가져온날": manifest["가져온날"], "마스터바뀜": False, + "원본없음": False, "구조물": ["pipe@40.0"], "고침": False, } @@ -330,6 +332,11 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch): assert client.get(f"{base}/{FOLDER}/모름").status_code == 422 assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"] assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400 + made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json() + assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True + names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]} + assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름 + assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404 session["company_id"] = 4 assert client.get(base).status_code == 403 assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403 From d94f3f78c19cb0dbea53633a448d0a9ef5c10655 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:49:43 +0900 Subject: [PATCH 7/9] =?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=E2=80=94?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=C2=B7=20=EA=B8=80=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EB=B9=84=20=C2=B7=20=EC=83=88=EB=A1=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C=20=EB=84=A3=EC=9D=80=20=EA=B0=92=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=C2=B7=20=ED=8E=B8=EC=A7=91=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=86=92=EC=9D=B4=20(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_item_picker_detail: 복사본 창구(가짜 · 약속 길 · 403 까닭) · 입력 목록 · 카드 순수 함수 · 한 벌 지킴 · 원본 층에 안 씀 - 자동값 · 확인 표시 글자 대비 4.5 이상 · 고르기 값은 원래 모양(숫자)으로 저장 - 새로 가져오기 = 원본에서 온 문서만 덮고 입력값은 남김 - 모달 폭 두 클래스로(공용 모달 폭에 안 밀림) · CAD · 시트 편집 높이 채움 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M02_MasterTemplete_InputsCard.css | 20 +- .../M02_MasterTemplete_InputsCard.ts | 26 +- .../tester/helper_item_picker_project.cjs | 274 ++++++++++++++++++ resources/tester/test_item_picker_detail.py | 166 +++++++++++ ui_template/ui_template_item_picker.css | 33 ++- .../ui_template_item_picker_project.ts | 12 +- 6 files changed, 504 insertions(+), 27 deletions(-) create mode 100644 resources/tester/helper_item_picker_project.cjs create mode 100644 resources/tester/test_item_picker_detail.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css index f8561d0d..66fb2c30 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css @@ -20,7 +20,7 @@ .m02-inputs__places, .m02-inputs__missing { margin: 0; - color: var(--color-text-muted); + color: var(--color-text-secondary); font-size: var(--text-caption); } @@ -63,31 +63,35 @@ .m02-inputs__control { flex: 1 1 6rem; min-width: 0; + max-width: 24rem; } -/* 자동값 = 저장값이 아님 — 흐리게 */ +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ .m02-inputs__value.is-auto, -.m02-inputs__control.is-auto { - color: var(--color-text-muted); +.m02-inputs__control.is-auto, +.m02-inputs__control::placeholder { + color: var(--color-text-secondary); font-style: italic; + opacity: 1; } .m02-inputs__state { - color: var(--color-text-muted); + color: var(--color-text-secondary); font-size: var(--text-caption); white-space: nowrap; } .m02-inputs__state.is-confirmed { - color: var(--color-success, var(--color-text)); + color: var(--color-text); + font-weight: 600; } .m02-inputs__error { flex: 1 0 100%; - color: var(--color-danger, var(--color-text)); + color: var(--color-danger); font-size: var(--text-caption); } .m02-inputs__control[aria-invalid="true"] { - border-color: var(--color-danger, currentcolor); + border-color: var(--color-danger); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts index 9bcbcb51..ab480e5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts @@ -73,7 +73,7 @@ const TEXT = { missing: "없는 로직 {value}", auto: "자동값", unconfirmed: "확인 안 함", - confirmed: "확인함", + confirmed: "✓ 확인함", confirmBox: "확인", outOfRange: "범위 {value} 밖", summary: "선택 {value}", @@ -94,13 +94,16 @@ export function rangeOf(input: LogicInput): { min?: number; max?: number } | nul return null; } -/** 고르기 목록 → 값 · 이름 짝 */ -export function choicesOf(input: LogicInput): { 값: string; 이름: string }[] { - return (input.고르기 ?? []).map((one) => - typeof one === "object" - ? { 값: String(one.값 ?? one.이름 ?? ""), 이름: String(one.이름 ?? one.값 ?? "") } - : { 값: String(one), 이름: String(one) }, - ); +/** 고르기 목록 → 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: LogicInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const raw = typeof one === "object" ? (one.값 ?? one.이름 ?? "") : one; + return { + 값: String(raw), + 이름: typeof one === "object" ? String(one.이름 ?? raw) : String(one), + raw, + }; + }); } /** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ @@ -277,7 +280,12 @@ export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { ? fill(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`) : ""; control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = raw === "" ? null : range ? Number(raw) : raw; + const value: string | number | null = + raw === "" + ? null + : range + ? Number(raw) + : (choices.find((one) => one.값 === raw)?.raw ?? raw); // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) store(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); box.checked = raw !== "" && !bad; diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs new file mode 100644 index 00000000..45749bfe --- /dev/null +++ b/resources/tester/helper_item_picker_project.cjs @@ -0,0 +1,274 @@ +/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와 + * 선택 입력 카드의 순수 함수를 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. 결과는 JSON 한 벌. + * ① 가짜 복사본(서버 길 없음): 가져오기 · 고침 · 다시 읽기 · 마스터가 바뀜 · 새로 가져오기 · 원본 그대로 + * ② 실서버 모양(약속 길): 목록 · 읽기 · 고침 PUT · 권한 없음(403) 까닭 그대로 · 가져오기 · 새로 가져오기 + * ③ 입력 목록: 약속 길 그대로 · 없으면 M01 길로 짜 맞춤 · 가짜 연결(모두 선택) + * ④ 카드 순수 함수: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api" }, + // 카드 순수 함수만 볼 것 — 화면 부품은 안 씀 + "@ui/ui_template_elements": { el: () => ({}), createButton: () => ({}) }, + "./M02_MasterTemplete_ItemCards": { itemCard: () => ({}), itemNote: () => ({}) }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +// ── 브라우저 대역 ───────────────────────────────────────────────────────────── +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => void store.set(k, String(v)), +}; + +/** 원본 층(시스템) — 열 rv 의 도면 · 면적 · 재료 · 판은 바뀔 수 있음 */ +const original = { + version: "v1", + docs: { + structure: { 양식: "구조물도면", 도번: "SD01", 도면: { entities: [1, 2] } }, + area: { 면적번: "SA01", 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, + material: { 재료번: "SM01", 시트: [{ id: "s", 칸: {} }] }, + }, +}; +const originalSnapshot = () => JSON.stringify(original); + +const reply = (status, body) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => body, +}); + +/** 가짜 서버 — mode: "fake"(프로젝트 길 없음) | "real"(약속 길) */ +const state = { mode: "fake", readOnly: false, inputsRoute: false, calls: [], server: {} }; + +globalThis.fetch = async (url, init = {}) => { + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(init.body) : undefined; + state.calls.push({ method, url, body }); + const m = /^\/api\/m02\/templates\/(structure|area|material)\/(.+)$/.exec(url); + if (m) { + return reply(200, { + 종류: m[1], + 이름: decodeURIComponent(m[2]), + 판: original.version, + 문서: JSON.parse(JSON.stringify(original.docs[m[1]])), + }); + } + const projects = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); + if (projects) { + if (state.mode === "fake") return reply(404, { detail: "Not Found" }); + const [, , folder, part] = projects; + if (!folder && method === "GET") + return reply( + 200, + Object.values(state.server).map((x) => x.info), + ); + if (!folder && method === "POST") { + const key = `${body.layer}_${body.owner ?? "none"}_${body.key}`; + state.server[key] = { + info: { 폴더: key, 원본: body, 원본판: original.version, 가져온날: "2026-09-29" }, + parts: { + 도면: JSON.parse(JSON.stringify(original.docs.structure)), + 면적: JSON.parse(JSON.stringify(original.docs.area)), + 재료: JSON.parse(JSON.stringify(original.docs.material)), + }, + }; + return reply(200, state.server[key].info); + } + const item = state.server[decodeURIComponent(folder)]; + if (!item) return reply(404, { detail: "없는 폴더" }); + if (part === "refresh" && method === "POST") { + item.parts.면적 = JSON.parse(JSON.stringify(original.docs.area)); + item.info.원본판 = original.version; + return reply(200, item.info); + } + if (method === "GET") { + const doc = item.parts[decodeURIComponent(part)]; + return doc === undefined ? reply(404, { detail: "부분 없음" }) : reply(200, doc); + } + if (method === "PUT") { + if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); + item.parts[decodeURIComponent(part)] = body; + return reply(200, { ok: true }); + } + } + const inputs = /^\/api\/m02\/layers\/(\w+)\/items\/([^/]+)\/inputs/.exec(url); + if (inputs) { + return state.inputsRoute + ? reply(200, { 일위대가: "UAREAL", 이름: "실서버", 로직: [], 빠진로직: [] }) + : reply(404, { detail: "Not Found" }); + } + if (url.startsWith("/api/m01/combo?key=")) { + return reply(200, { + combo: { 이름: "기슭막이", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }, { 로직: "GX" }] }, + }); + } + if (url.startsWith("/api/m01/logic/auto?key=")) { + return reply(200, { 값: { 지역: "전국평균", 소량감률: 0 } }); + } + const logic = /^\/api\/m01\/logic\?key=(\w+)/.exec(url); + if (logic) { + if (logic[1] === "GX") return reply(404, { detail: "없는 로직" }); + return reply(200, { + logic: { + 이름: `로직 ${logic[1]}`, + 결과단위: "원/m", + 입력: [ + { 이름: "지역", 고르기: ["전국평균", "서울"] }, + { 이름: "소량감률", 단위: "%", 범위: [0, 50] }, + ], + }, + }); + } + return reply(404, { detail: "Not Found" }); +}; + +const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts")); +const card = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_InputsCard.ts")); + +const row = (over = {}) => ({ + key: "SS000001", + 열: "rv", + layer: "system", + owner: null, + 일위대가: "UA1", + ...over, +}); +const clone = (x) => JSON.parse(JSON.stringify(x)); + +async function main() { + const out = {}; + + // ① 폴더 이름 — 서버 `snapshot_folder_name` 과 같은 글 + out.folders = [ + project.folderOf(row()), + project.folderOf(row({ layer: "company", owner: "3", key: "SS000001" })), + project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), + ]; + + // ② 가짜 복사본 — 서버 길이 없을 때 + state.mode = "fake"; + const fake = project.projectItems("P-FAKE"); + const before = originalSnapshot(); + out.fake = { list: await fake.list() }; + out.fake.isFake = fake.isFake(); + const info = await fake.importItem(row()); + out.fake.info = info; + out.fake.readBack = await fake.read(info.폴더, "면적"); + await fake.write(info.폴더, "면적", { + ...out.fake.readBack, + 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }], + }); + await fake.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); + out.fake.afterWrite = await fake.read(info.폴더, "면적"); + out.fake.listAfter = (await fake.list()).map((one) => one.폴더); + out.fake.changedNow = await fake.masterChanged(row(), info); + original.version = "v2"; + out.fake.changedAfterMaster = await fake.masterChanged(row(), info); + await fake.refresh(row(), info); + out.fake.afterRefresh = await fake.read(info.폴더, "면적"); + out.fake.valuesKept = await fake.read(info.폴더, "입력값"); + out.fake.changedAfterRefresh = await fake.masterChanged(row(), (await fake.list())[0]); + out.fake.originalUntouched = before.replace('"v1"', '"v2"') === originalSnapshot(); + out.fake.storageKeys = [...store.keys()]; + await fake.write("없는폴더", "면적", {}).then( + () => (out.fake.writeMissing = "성공"), + (error) => (out.fake.writeMissing = error.message), + ); + original.version = "v1"; + + // ③ 실서버 모양 — 약속 길 + state.mode = "real"; + state.calls = []; + const real = project.projectItems("P-REAL"); + out.real = { list: await real.list(), isFake: real.isFake() }; + const rinfo = await real.importItem(row({ layer: "personal", owner: "1/3" })); + out.real.info = rinfo; + out.real.importCall = state.calls.find((c) => c.method === "POST"); + out.real.read = await real.read(rinfo.폴더, "면적"); + out.real.readMissing = await real.read(rinfo.폴더, "고른자재"); + await real.write(rinfo.폴더, "입력값", { L: { 이름: { 값: 1, 확인: true } } }); + out.real.putCall = state.calls.filter((c) => c.method === "PUT").map((c) => c.url); + state.readOnly = true; + await real.write(rinfo.폴더, "면적", {}).then( + () => (out.real.readOnlyWrite = "성공"), + (error) => (out.real.readOnlyWrite = error.message), + ); + state.readOnly = false; + await real.refresh(row({ layer: "personal", owner: "1/3" }), rinfo); + out.real.refreshCall = state.calls + .filter((c) => /refresh$/.test(c.url)) + .map((c) => `${c.method} ${c.url}`); + out.real.changedFlag = await real.masterChanged(row(), { ...rinfo, 마스터바뀜: true }); + + // ④ 입력 목록 + state.inputsRoute = false; + const madeUp = await project.loadInputs(row({ key: "SS000009" })); + out.inputs = { + fake: madeUp.fake, + logics: madeUp.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]), + missing: madeUp.list.빠진로직, + auto: madeUp.list.로직[0].입력.map((i) => i.자동값), + link: project.fakeLink(madeUp.list).줄.length, + linkPlaces: [...new Set(project.fakeLink(madeUp.list).줄.map((r) => r.자리))], + }; + state.inputsRoute = true; + const realInputs = await project.loadInputs(row({ key: "SS000010" })); + out.inputs.real = { fake: realInputs.fake, name: realInputs.list.이름 }; + + // ⑤ 카드 순수 함수 + out.card = { + range: [ + card.rangeOf({ 이름: "a", 범위: [0, 50] }), + card.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), + card.rangeOf({ 이름: "c" }), + ], + choices: card.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), + keys: [card.inputKey("GA1", "지역"), card.pillKey("GA1", "지역")], + groups: card + .pickedGroups(madeUp.list, { + 판: "x", + 줄: [ + { 로직: "GA1", 입력: "지역", 자리: "선택" }, + { 로직: "GA1", 입력: "소량감률", 자리: "면적" }, + { 로직: "GA2", 입력: "소량감률", 자리: "선택" }, + ], + }) + .map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]), + noLink: card.pickedGroups(madeUp.list, null).length, + }; + return out; +} + +main().then( + (out) => process.stdout.write(JSON.stringify(out)), + (error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }, +); diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py new file mode 100644 index 00000000..7becafeb --- /dev/null +++ b/resources/tester/test_item_picker_detail.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +"""항목 고르기 모달 상세(계획서 25-2d) — 자료 창구 · 선택 입력 카드 · 한 벌 지킴. + +① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(가짜 서버 fetch 대역) 본다: + 서버 길이 없을 때의 가짜 복사본(가져오기 · 고침 · 마스터가 바뀜 · 새로 가져오기 · **원본 그대로**) · + 약속 길(목록 · 읽기 · PUT · 403 까닭 그대로 · 가져오기 · 새로 가져오기) · 입력 목록(약속 길 · M01 로 짜 맞춤). +② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from common_util.common_util_summary_item import snapshot_folder_name + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +UI = ROOT / "ui_template" +M02 = ROOT / "M02_MasterTemplete" + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_item_picker_project.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): + rows = [ + {"layer": "system", "owner": None, "key": "SS000001"}, + {"layer": "company", "owner": "3", "key": "SS000001"}, + {"layer": "personal", "owner": "3/12", "key": "SS000002"}, + ] + assert out["folders"] == [snapshot_folder_name(row) for row in rows] + + +def test_가짜_복사본_가져와_고치고_다시_읽으면_고친_값(out): + fake = out["fake"] + assert fake["isFake"] is True and fake["list"] == [] # 서버 길이 없음 → 가짜 + assert fake["info"]["폴더"] == "system_none_SS000001" + assert fake["readBack"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 가져온 그대로 + assert fake["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} # 고침이 남음 + assert fake["listAfter"] == ["system_none_SS000001"] + + +def test_가짜_복사본을_고쳐도_원본은_그대로(out): + assert out["fake"]["originalUntouched"] is True + assert out["fake"]["storageKeys"] == ["aislo.itemsFake.P-FAKE"] # 브라우저 저장소 한 칸만 + + +def test_마스터가_바뀌면_표시_새로_가져오면_원본으로_덮고_넣은_값은_남김(out): + fake = out["fake"] + assert fake["changedNow"] is False + assert fake["changedAfterMaster"] is True + assert fake["afterRefresh"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 고침이 원본으로 덮임 + assert fake["valuesKept"]["GA1"]["지역"] == { + "값": "서울", + "확인": True, + } # 사용자가 넣은 값은 남음 + assert fake["changedAfterRefresh"] is False + + +def test_안_가져온_항목에_쓰면_까닭을_던짐(out): + assert out["fake"]["writeMissing"] == "프로젝트에 가져오지 않은 항목" + + +def test_약속_길_목록_읽기_고침_가져오기_새로_가져오기(out): + real = out["real"] + assert real["isFake"] is False # 길이 있으면 가짜를 안 탐 + assert real["importCall"] == { + "method": "POST", + "url": "/api/projects/P-REAL/items", + "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, + } + assert real["readMissing"] is None # 부분이 없음 = null + assert len(real["putCall"]) == 1 and real["putCall"][0].endswith("/%EC%9E%85%EB%A0%A5%EA%B0%92") + assert real["refreshCall"][0].startswith("POST /api/projects/P-REAL/items/") + assert real["refreshCall"][0].endswith("/refresh") + assert real["changedFlag"] is True # 실서버는 `마스터바뀜` 을 그대로 씀 + + +def test_권한_없음은_서버_까닭_그대로(out): + assert out["real"]["readOnlyWrite"] == "프로젝트 편집 권한이 없음" + + +def test_입력_목록_약속_길이_있으면_그대로_없으면_M01_로_짜_맞춤(out): + inputs = out["inputs"] + assert inputs["real"] == {"fake": False, "name": "실서버"} + assert inputs["fake"] is True + # 로직마다 갈라 — 이름이 같아도 합치지 않음 · 없는 로직은 빠진로직 + assert inputs["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역", "소량감률"]]] + assert inputs["missing"] == ["GX"] + assert inputs["auto"] == ["전국평균", 0] + assert inputs["link"] == 4 and inputs["linkPlaces"] == ["선택"] # 가짜 연결 = 모두 선택 + + +def test_선택_입력_카드_순수_함수(out): + card = out["card"] + assert card["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] + assert [c["raw"] for c in card["choices"]] == [15, "16", 18] # 저장은 원래 모양 + assert card["keys"] == ["GA1|지역", "입력|GA1|지역"] # 25-1b 키 모양 + assert card["groups"] == [["GA1", ["지역"]], ["GA2", ["소량감률"]]] # 자리 「선택」 만 + assert card["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 + + +def test_모달은_M02_와_같은_카드_한_벌을_씀(): + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + structure = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8") + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + assert "createItemCards(" in structure and "mountDrawingTemplate(" not in structure + assert 'import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards")' in detail + assert "createItemCards(" in detail + # 카드를 모달에서 또 그리지 않음 — 미리보기 부품은 카드 파일 한 곳 + for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker.ts"): + text = (UI / name).read_text(encoding="utf-8") + assert "mountDrawingTemplate(" not in text and "createSpreadsheet(" not in text, name + assert cards.count("mountDrawingTemplate(") == 1 + + +def test_모달은_원본_층에_쓰는_길을_안_부름(): + """마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐.""" + for name in ( + "ui_template_item_picker_detail.ts", + "ui_template_item_picker_edit.ts", + "ui_template_item_picker_project.ts", + ): + text = (UI / name).read_text(encoding="utf-8") + for forbidden in ( + "saveTemplate(", + "deleteTemplate(", + "deleteLayerTemplate(", + "copyToPersonal(", + ): + assert forbidden not in text, f"{name} 이 {forbidden} 를 부름" + edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8") + assert edit.count("items.write(") >= 3 # 도면 · 시트 · 입력값 모두 복사본으로 + + +def test_B05_구조물_폼이_projectId_를_모달에_넘김(): + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( + encoding="utf-8" + ) + assert re.search(r"projectId: sectionOptions\.projectId \?\? null", panel) + shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") + assert "projectId: shared.projectId" in shared + + +def test_소스가_700줄을_넘지_않음(): + for path in ( + *sorted(UI.glob("ui_template_item_picker*.ts")), + M02 / "M02_MasterTemplete_ItemCards.ts", + M02 / "M02_MasterTemplete_ItemSource.ts", + M02 / "M02_MasterTemplete_InputsCard.ts", + ): + assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 508023a2..4d8d73dd 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -1,5 +1,6 @@ /* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */ -.ui-item-picker { +/* 공용 모달(.ui-modal)의 폭 · 높이보다 앞서게 — 불러오는 차례에 따라 뒤집히지 않도록 두 클래스 */ +.ui-modal.ui-item-picker { width: min(1320px, 96vw); max-height: 94vh; } @@ -141,22 +142,40 @@ .ui-item-picker__edit-sheet, .ui-item-picker__edit-cad { + display: flex; flex: 1; min-width: 0; min-height: 0; } -.ui-item-picker__edit-inputs { - flex: 1; - min-width: 0; - padding: var(--spacing-8); - overflow: auto; +/* 도면 부품(.m02-drawing)이 남는 높이를 채움 — M02 메인 칸과 같은 자리 규칙 */ +.ui-item-picker__edit-cad > .m02-drawing { + flex: 1 1 0; } -.ui-item-picker__edit-inputs .m02-struct__card { +.ui-item-picker__edit-sheet > .ss { + flex: 1 1 0; + min-width: 0; +} + +.ui-item-picker__edit-inputs { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + padding: var(--spacing-8); +} + +.ui-item-picker__edit-inputs > .m02-struct__card { + flex: 1 1 0; border: 0; } +.ui-item-picker__edit-inputs .m02-inputs__wrap { + min-height: 0; +} + .ui-item-picker__name { margin: 0; font-size: var(--text-body); diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 4ad1da45..d06fd77f 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -45,7 +45,7 @@ export interface ProjectItemInfo { /** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ export const folderOf = (row: PickerRow): string => - `${layerOf(row)}_${(ownerOf(row) ?? "none").replace("/", "-")}_${row.key}`; + `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; interface Reply { status: number; @@ -207,8 +207,14 @@ export function projectItems(projectId: string): ProjectItems { if (reply.status >= 400) throw new Error(reasonOf(reply)); return reply.body as ProjectItemInfo; } - delete fakeLoad(projectId)[info.폴더]; - return client.importItem(row); + // 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로 + const kept = fakeLoad(projectId)[info.폴더]?.parts; + const fresh = await client.importItem(row); + const item = fakeLoad(projectId)[fresh.폴더]; + for (const part of ["입력값", "고른자재"] as const) + if (kept?.[part] !== undefined && item) item.parts[part] = kept[part]; + fakeSave(projectId); + return fresh; }, masterChanged: async (row, info) => { if (!fake) return info.마스터바뀜 === true; From 3a59f715f6c035748207b520560b2549eabb5b05 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 00:06:37 +0900 Subject: [PATCH 8/9] =?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=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=84=9C=EB=B2=84=EC=97=90=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20sub2=20=ED=8C=8C=EC=9D=BC=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=20(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복사본 창구를 sub6 길 모양({항목} · {종류, 판, 내용} · PUT {판, 내용} · 409 message · refresh · calc)으로 — 가짜 응답 지움 - 입력 목록 · 항목 연결은 sub4 길(fetchItemInputs · readInputLinks) · 알약은 pillsOf - 값 받기 카드를 M02_MasterTemplete_Inputs.ts 에 모드 view · value 로 더함(InputsCard 지움 · 두 벌 금지) · 셈은 _Inputs_Model - 상태 줄에 복사본 계산 한 줄(계 · 빠진 입력) · 목록은 모달에서 한 번만 받아 들고 있음 - Structure.ts: sub2 의 선택 입력 카드를 ItemCards 의 extraCards 로 다시 얹음 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M02_MasterTemplete_Inputs.css | 98 +++++ .../M02_MasterTemplete_Inputs.ts | 297 ++++++++++++- .../M02_MasterTemplete_InputsCard.css | 97 ----- .../M02_MasterTemplete_InputsCard.ts | 397 ------------------ .../M02_MasterTemplete_Inputs_Model.ts | 48 +++ .../tester/helper_item_picker_project.cjs | 315 ++++++-------- resources/tester/test_item_picker_detail.py | 126 +++--- ui_template/ui_template_item_picker_detail.ts | 64 +-- ui_template/ui_template_item_picker_edit.ts | 71 ++-- .../ui_template_item_picker_project.ts | 302 +++++-------- 10 files changed, 803 insertions(+), 1012 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.css delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index fff107c9..d90721be 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,3 +103,101 @@ .m02-inputs__seat:disabled { opacity: 0.7; } + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 3462dd1b..77a3c59c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,21 +18,28 @@ import { getActiveLayer, readInputLinks, saveInputLinks, + type InputLinks, type InputSeat, + type ItemInput, type ItemInputs, + type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, + choicesOf, countOf, inputKey, linesOf, + pickedGroups, pillsOf, + rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, + type ValueDoc, } from "./M02_MasterTemplete_Inputs_Model"; import "./M02_MasterTemplete_Inputs.css"; @@ -276,3 +283,291 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로 } } + +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── + * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 + * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 + * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. + * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ + +export interface InputsValueData { + list: ItemInputs; + link: InputLinks | null; + values: ValueDoc; +} + +export interface InputsValueOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsValueHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsValueData | null, note?: string) => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; +} + +const VALUE_TEXT = { + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "✓ 확인함", + confirmBox: "확인", + outOfRange: "범위 {value} 밖", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +type ValueEntry = ValueDoc[string][string]; + +export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { + let data: InputsValueData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputval__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputval" }); + const root = el("section", { + className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [ + el("h3", { className: "ui-collapsible__title", text: TEXT.title }), + el("div", { className: "m02-struct__subhead", children: [summary, edit] }), + el("div", { className: "m02-inputval__wrap", children: [places, body] }), + ], + }); + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const one = (logic: string, name: string): ValueEntry => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const put = (logic: string, name: string, next: ValueEntry): void => { + (values[logic] ??= {})[name] = next; + }; + const autoOf = (input: ItemInput): string | number | null => { + const auto = input.자동값; + return typeof auto === "string" || typeof auto === "number" ? auto : null; + }; + + const paintSummary = (): void => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (one(logic.로직, input.이름).확인) confirmed += 1; + } + summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ + const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { + const stored = one(logic.로직, input.이름); + const auto = autoOf(input); + const state = el("span", { className: "m02-inputval__state" }); + const paintState = (): void => { + const now = one(logic.로직, input.이름); + state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const choices = choicesOf(input); + const control = ( + choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) + : el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }) + ) as HTMLInputElement | HTMLSelectElement; + const error = el("span", { className: "m02-inputval__error" }); + const box = el("input", { + attrs: { + type: "checkbox", + title: VALUE_TEXT.confirmBox, + "aria-label": VALUE_TEXT.confirmBox, + }, + }) as HTMLInputElement; + + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, + }), + ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ); + } + control.value = isEmpty(stored.값) ? "" : String(stored.값); + control.classList.toggle("is-auto", isEmpty(stored.값)); + box.checked = stored.확인; + + const outside = (raw: string): boolean => { + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); + }; + let wasBad = false; + const changed = (): void => { + const raw = control.value; + const bad = outside(raw); + if (bad !== wasBad) invalid += bad ? 1 : -1; + wasBad = bad; + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) + : ""; + control.classList.toggle("is-auto", raw === ""); + const value: string | number | null = + raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); + box.checked = raw !== "" && !bad; + emit(); + }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = one(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + put(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); + }; + + const paint = (note?: string): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + body.replaceChildren( + el("p", { + className: "m02-struct__empty", + text: note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none, + }), + ); + return; + } + const seats = [...seatsOf(data.link).values()]; + const area = seats.filter((seat) => seat === "면적").length; + const material = seats.filter((seat) => seat === "재료").length; + places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; + const shown = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputval__missing", + text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), + }), + ] + : []; + if (!shown.length) { + body.replaceChildren( + el("p", { className: "m02-struct__empty", text: VALUE_TEXT.none }), + ...missing, + ); + return; + } + body.replaceChildren( + ...shown.map(({ logic, inputs }) => + el("section", { + className: "m02-inputval__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputval__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputval__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note); + }, + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, entry] of Object.entries(byName)) + if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css deleted file mode 100644 index 66fb2c30..00000000 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css +++ /dev/null @@ -1,97 +0,0 @@ -/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 · 값 칸 · 확인 표시. 카드 틀은 M02_MasterTemplete_Structure.css */ -.m02-inputs__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputs { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputs__places, -.m02-inputs__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputs__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputs__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputs__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputs__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputs__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputs__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputs__value.is-auto, -.m02-inputs__control.is-auto, -.m02-inputs__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputs__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputs__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputs__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputs__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts deleted file mode 100644 index ab480e5e..00000000 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts +++ /dev/null @@ -1,397 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_InputsCard.ts - * 「선택 입력」 카드(계획서 25장) — 구조물 집계 항목의 일위대가 조합이 담은 단가산출 로직들의 입력을 - * **로직마다 묶어** 보이는 카드. 카드 틀은 도면 · 면적산출 · 재료산출 카드와 같음(`ItemCards`). - * - * mode "view" = 보기만 — 자리가 「선택」 인 입력의 값(없으면 흐린 자동값 + 「확인 안 함」) - * mode "value" = 값 받기 — 프로젝트 복사본(고르기 = 목록 · 범위 = 숫자 · 자동값 흐림 · 확인 표시) - * mode "link" = 자리 고르기(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀) — M02 세 층 공용(sub2 · 25-2c) - * - * 값은 로직마다 따로 — 이름이 같아도 합치지 않음(`{로직}|{입력이름}`). 입력이 없어도 자동값은 흐린 - * 미리보기일 뿐 저장값이 아님(25-1: 확인 안 한 입력은 계산 때 「빠진 입력」 목록). - * 자료 모양은 25-1b 약속 그대로 — 아래 타입. - * ========================================================================== */ - -import { createButton, el } from "@ui/ui_template_elements"; -import { itemCard, itemNote } from "./M02_MasterTemplete_ItemCards"; -import "./M02_MasterTemplete_InputsCard.css"; - -/** 입력 목록 한 입력(25-1b) — `고르기` 는 문자열 목록 · 객체 목록 둘 다 받음 */ -export interface LogicInput { - 이름: string; - 설명?: string | null; - 단위?: string | null; - 고르기?: Array | null; - 범위?: [number, number] | { 최소?: number; 최대?: number; min?: number; max?: number } | null; - 자동값?: string | number | null; -} - -export interface InputsLogic { - 로직: string; - 이름: string; - 결과단위?: string | null; - 입력: LogicInput[]; -} - -/** `GET /api/m02/layers/{layer}/items/{key}/inputs` */ -export interface InputsList { - 일위대가: string; - 이름: string; - 로직: InputsLogic[]; - 빠진로직: string[]; -} - -export type InputPlace = "면적" | "재료" | "선택" | null; - -/** 항목 연결 한 줄 — 자리 면적 · 재료 = 그 시트 알약 · 선택 = 이 카드 */ -export interface LinkRow { - 로직: string; - 입력: string; - 자리: InputPlace; - 알약?: string; -} - -export interface LinkDoc { - 판: string; - 줄: LinkRow[]; -} - -/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` */ -export type ValueDoc = Record< - string, - Record ->; - -/** 입력 하나의 키 · 알약 키(25-1b) */ -export const inputKey = (logic: string, name: string): string => `${logic}|${name}`; -export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`; - -const TEXT = { - title: "선택 입력", - edit: "선택 입력 수정", - none: "선택으로 이은 입력이 없음", - missing: "없는 로직 {value}", - auto: "자동값", - unconfirmed: "확인 안 함", - confirmed: "✓ 확인함", - confirmBox: "확인", - outOfRange: "범위 {value} 밖", - summary: "선택 {value}", - places: "면적산출 알약 {area} · 재료산출 알약 {material}", - noList: "입력 목록을 못 읽음 — {value}", -}; - -const fill = (text: string, value: string): string => text.replace("{value}", value); -const num = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 */ -export function rangeOf(input: LogicInput): { min?: number; max?: number } | null { - const raw = input.범위; - if (Array.isArray(raw)) return { min: num(raw[0]), max: num(raw[1]) }; - if (raw && typeof raw === "object") - return { min: num(raw.최소 ?? raw.min), max: num(raw.최대 ?? raw.max) }; - return null; -} - -/** 고르기 목록 → 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ -export function choicesOf(input: LogicInput): { 값: string; 이름: string; raw: string | number }[] { - return (input.고르기 ?? []).map((one) => { - const raw = typeof one === "object" ? (one.값 ?? one.이름 ?? "") : one; - return { - 값: String(raw), - 이름: typeof one === "object" ? String(one.이름 ?? raw) : String(one), - raw, - }; - }); -} - -/** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ -export function pickedGroups( - list: InputsList, - link: LinkDoc | null, -): { logic: InputsLogic; inputs: LogicInput[] }[] { - const picked = new Set( - (link?.줄 ?? []) - .filter((row) => row.자리 === "선택") - .map((row) => inputKey(row.로직, row.입력)), - ); - return list.로직 - .map((logic) => ({ - logic, - inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))), - })) - .filter((group) => group.inputs.length > 0); -} - -export interface InputsCardData { - list: InputsList; - link: LinkDoc | null; - values: ValueDoc; -} - -export interface InputsCardOptions { - mode: "view" | "value"; - /** mode "view" — [선택 입력 수정] */ - onEdit?: () => void; - /** mode "value" — 값이 바뀔 때마다 */ - onChange?: (values: ValueDoc) => void; -} - -export interface InputsCardHandle { - root: HTMLElement; - /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ - set: (data: InputsCardData | null, note?: string) => void; - /** 지금 값(확인한 것 · 값이 든 것만) */ - getValues: () => ValueDoc; - /** 범위 밖 값이 없나 */ - isValid: () => boolean; - /** [선택 입력 수정] 글 — 아직 프로젝트로 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ - setEditLabel: (label: string) => void; - /** 자리 「선택」 인 입력 수 · 확인한 수 */ - counts: () => { total: number; confirmed: number }; -} - -export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { - let data: InputsCardData | null = null; - let values: ValueDoc = {}; - let invalid = 0; - - const summary = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" }); - edit.hidden = opts.mode !== "view"; - if (opts.onEdit) edit.addEventListener("click", opts.onEdit); - const places = el("p", { className: "m02-inputs__places" }); - const body = el("div", { className: "m02-struct__sheet m02-inputs" }); - const root = itemCard( - TEXT.title, - el("div", { - className: "m02-struct__subhead", - children: [summary, edit], - }), - el("div", { className: "m02-inputs__wrap", children: [places, body] }), - ); - - const groups = (): ReturnType => - data ? pickedGroups(data.list, data.link) : []; - const valueOf = (logic: string, name: string): { 값: string | number | null; 확인: boolean } => - values[logic]?.[name] ?? { 값: null, 확인: false }; - const isEmpty = (value: string | number | null): boolean => value === null || value === ""; - const store = ( - logic: string, - name: string, - next: { 값: string | number | null; 확인: boolean }, - ) => { - (values[logic] ??= {})[name] = next; - }; - - const counts = (): { total: number; confirmed: number } => { - let total = 0; - let confirmed = 0; - for (const { logic, inputs } of groups()) - for (const input of inputs) { - total += 1; - if (valueOf(logic.로직, input.이름).확인) confirmed += 1; - } - return { total, confirmed }; - }; - const paintSummary = (): void => { - const { total, confirmed } = counts(); - summary.textContent = data ? `${fill(TEXT.summary, `${confirmed}/${total}`)}` : ""; - }; - - /** 값 칸 하나 — 자동값은 흐리게(placeholder · 첫 줄) */ - const valueCell = (logic: InputsLogic, input: LogicInput): HTMLElement => { - const stored = valueOf(logic.로직, input.이름); - const auto = input.자동값 ?? null; - const state = el("span", { className: "m02-inputs__state" }); - const paintState = (): void => { - const now = valueOf(logic.로직, input.이름); - state.textContent = now.확인 ? TEXT.confirmed : TEXT.unconfirmed; - state.classList.toggle("is-confirmed", now.확인); - }; - if (opts.mode === "view") { - const text = isEmpty(stored.값) ? auto : stored.값; - const shown = el("span", { - className: `m02-inputs__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: TEXT.auto } : {}, - }); - paintState(); - return el("div", { className: "m02-inputs__cell", children: [shown, state] }); - } - - const emit = (): void => { - paintState(); - paintSummary(); - opts.onChange?.(values); - }; - const range = rangeOf(input); - const choices = choicesOf(input); - const control: HTMLInputElement | HTMLSelectElement = choices.length - ? el("select", { className: "ui-select ui-select--sm m02-inputs__control" }) - : el("input", { - className: "ui-input m02-inputs__control", - attrs: { - type: range ? "number" : "text", - ...(range?.min !== undefined ? { min: String(range.min) } : {}), - ...(range?.max !== undefined ? { max: String(range.max) } : {}), - ...(range ? { step: "any" } : {}), - placeholder: auto === null ? "" : String(auto), - }, - }); - const error = el("span", { className: "m02-inputs__error" }); - const box = el("input", { - attrs: { type: "checkbox", title: TEXT.confirmBox, "aria-label": TEXT.confirmBox }, - }); - - if (control instanceof HTMLSelectElement) { - control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${TEXT.auto} ${String(auto)}`, - }), - ...choices.map((one) => el("option", { attrs: { value: one.값 }, text: one.이름 })), - ); - control.value = isEmpty(stored.값) ? "" : String(stored.값); - } else { - control.value = isEmpty(stored.값) ? "" : String(stored.값); - } - control.classList.toggle("is-auto", isEmpty(stored.값)); - box.checked = stored.확인; - - const outside = (raw: string): boolean => { - if (!range || raw.trim() === "") return false; - const value = Number(raw); - return ( - !Number.isFinite(value) || - (range.min !== undefined && value < range.min) || - (range.max !== undefined && value > range.max) - ); - }; - let wasBad = false; - const changed = (): void => { - const raw = control.value; - const bad = outside(raw); - if (bad !== wasBad) invalid += bad ? 1 : -1; - wasBad = bad; - control.setAttribute("aria-invalid", bad ? "true" : "false"); - error.textContent = bad - ? fill(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`) - : ""; - control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = - raw === "" - ? null - : range - ? Number(raw) - : (choices.find((one) => one.값 === raw)?.raw ?? raw); - // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) - store(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); - box.checked = raw !== "" && !bad; - emit(); - }; - control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); - // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 - box.addEventListener("change", () => { - const now = valueOf(logic.로직, input.이름); - if (box.checked && isEmpty(now.값) && auto !== null) { - control.value = String(auto); - control.classList.remove("is-auto"); - store(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); - } else if (box.checked && isEmpty(now.값)) { - box.checked = false; // 받아들일 값이 없음 - } else { - store(logic.로직, input.이름, { ...now, 확인: box.checked }); - } - emit(); - }); - paintState(); - return el("div", { - className: "m02-inputs__cell", - children: [control, box, state, error], - }); - }; - - const paint = (note?: string): void => { - invalid = 0; - paintSummary(); - if (!data) { - places.textContent = ""; - body.replaceChildren(itemNote(note ? fill(TEXT.noList, note) : TEXT.none)); - return; - } - const rows = data.link?.줄 ?? []; - const area = rows.filter((row) => row.자리 === "면적").length; - const material = rows.filter((row) => row.자리 === "재료").length; - places.textContent = - area + material - ? TEXT.places.replace("{area}", `${area}`).replace("{material}", `${material}`) - : ""; - const shownGroups = groups(); - const missing = data.list.빠진로직.length - ? [ - el("p", { - className: "m02-inputs__missing", - text: fill(TEXT.missing, data.list.빠진로직.join(", ")), - }), - ] - : []; - if (!shownGroups.length) { - body.replaceChildren(itemNote(TEXT.none), ...missing); - return; - } - body.replaceChildren( - ...shownGroups.map(({ logic, inputs }) => - el("section", { - className: "m02-inputs__logic", - attrs: { "data-logic": logic.로직 }, - children: [ - el("h4", { - className: "m02-inputs__logic-name", - text: logic.이름 || logic.로직, - attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, - }), - ...inputs.map((input) => - el("div", { - className: "m02-inputs__row", - attrs: { "data-input": inputKey(logic.로직, input.이름) }, - children: [ - el("span", { - className: "m02-inputs__name", - text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, - attrs: input.설명 ? { title: input.설명 } : {}, - }), - valueCell(logic, input), - ], - }), - ), - ], - }), - ), - ...missing, - ); - }; - - return { - root, - set: (next, note) => { - data = next; - values = next ? structuredClone(next.values) : {}; - paint(note); - }, - getValues: () => { - // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) - const out: ValueDoc = {}; - for (const [logic, byName] of Object.entries(values)) - for (const [name, one] of Object.entries(byName)) - if (one.확인 || !isEmpty(one.값)) (out[logic] ??= {})[name] = one; - return out; - }, - isValid: () => invalid === 0, - setEditLabel: (label) => { - (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; - }, - counts, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index f6baf434..a6ab719f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -101,3 +101,51 @@ export function valueHint(input: ItemInput): string { /** 자동값 글 — 없으면 빈 글 */ export const autoText = (input: ItemInput): string => text(input.자동값); + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ +export function pickedGroups( + list: ItemInputs, + links: InputLinks | null, +): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] { + const picked = new Set( + [...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key), + ); + return list.로직 + .map((logic) => ({ + logic, + inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))), + })) + .filter((group) => group.inputs.length > 0); +} diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 45749bfe..25343dbf 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -1,9 +1,9 @@ /* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와 - * 선택 입력 카드의 순수 함수를 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. 결과는 JSON 한 벌. - * ① 가짜 복사본(서버 길 없음): 가져오기 · 고침 · 다시 읽기 · 마스터가 바뀜 · 새로 가져오기 · 원본 그대로 - * ② 실서버 모양(약속 길): 목록 · 읽기 · 고침 PUT · 권한 없음(403) 까닭 그대로 · 가져오기 · 새로 가져오기 - * ③ 입력 목록: 약속 길 그대로 · 없으면 M01 길로 짜 맞춤 · 가짜 연결(모두 선택) - * ④ 카드 순수 함수: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. + * 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌. + * ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403) + * ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음) + * ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ const fs = require("fs"); @@ -13,12 +13,7 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); -const STUBS = { - "@config/config_frontend": { API_BASE_URL: "/api" }, - // 카드 순수 함수만 볼 것 — 화면 부품은 안 씀 - "@ui/ui_template_elements": { el: () => ({}), createButton: () => ({}) }, - "./M02_MasterTemplete_ItemCards": { itemCard: () => ({}), itemNote: () => ({}) }, -}; +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; function loadTs(file) { const full = path.resolve(file); @@ -39,228 +34,174 @@ function loadTs(file) { return box.exports; } -// ── 브라우저 대역 ───────────────────────────────────────────────────────────── -const store = new Map(); -globalThis.localStorage = { - getItem: (k) => (store.has(k) ? store.get(k) : null), - setItem: (k, v) => void store.set(k, String(v)), -}; - -/** 원본 층(시스템) — 열 rv 의 도면 · 면적 · 재료 · 판은 바뀔 수 있음 */ -const original = { - version: "v1", - docs: { - structure: { 양식: "구조물도면", 도번: "SD01", 도면: { entities: [1, 2] } }, - area: { 면적번: "SA01", 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, - material: { 재료번: "SM01", 시트: [{ id: "s", 칸: {} }] }, - }, -}; -const originalSnapshot = () => JSON.stringify(original); - const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, json: async () => body, }); -/** 가짜 서버 — mode: "fake"(프로젝트 길 없음) | "real"(약속 길) */ -const state = { mode: "fake", readOnly: false, inputsRoute: false, calls: [], server: {} }; +/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */ +const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 }; +const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`; + +const INPUTS = { + 일위대가: "UA000004", + 이름: "기슭막이", + 로직: [ + { + 로직: "GA1", + 이름: "로직 A", + 결과단위: "원/m", + 입력: [ + { 이름: "지역", 고르기: ["전국평균", "서울"], 자동값: "전국평균" }, + { 이름: "소량감률", 단위: "%", 범위: [0, 50], 자동값: 0 }, + ], + }, + { 로직: "GA2", 이름: "로직 B", 입력: [{ 이름: "지역", 고르기: [15, 16] }] }, + ], + 빠진로직: ["GX"], +}; globalThis.fetch = async (url, init = {}) => { const method = init.method ?? "GET"; const body = init.body ? JSON.parse(init.body) : undefined; state.calls.push({ method, url, body }); - const m = /^\/api\/m02\/templates\/(structure|area|material)\/(.+)$/.exec(url); - if (m) { + if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) { + state.inputsHits += 1; + return reply(200, INPUTS); + } + const m = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); + if (!m) return reply(404, { detail: "Not Found" }); + const [, , folderEnc, partEnc] = m; + if (!folderEnc && method === "GET") + return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) }); + if (!folderEnc && method === "POST") { + const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`; + state.folders[folder] = { + info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, + parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, + }; + return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" }); + } + const item = state.folders[decodeURIComponent(folderEnc)]; + if (!item) return reply(404, { detail: "복사본이 없음" }); + const part = partEnc && decodeURIComponent(partEnc); + if (part === "refresh") { + item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }; + return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" }); + } + if (part === "calc") return reply(200, { - 종류: m[1], - 이름: decodeURIComponent(m[2]), - 판: original.version, - 문서: JSON.parse(JSON.stringify(original.docs[m[1]])), + 줄: [], + 계: 12345.6, + 빠진입력: [ + { 로직: "GA1", 입력: "지역" }, + { 로직: "GA2", 입력: "지역" }, + ], + 멈춤: ["GA1"], }); + if (method === "GET") { + const content = item.parts[part] ?? null; + return reply(200, { 종류: part, 판: versionOf(content), 내용: content }); } - const projects = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); - if (projects) { - if (state.mode === "fake") return reply(404, { detail: "Not Found" }); - const [, , folder, part] = projects; - if (!folder && method === "GET") - return reply( - 200, - Object.values(state.server).map((x) => x.info), - ); - if (!folder && method === "POST") { - const key = `${body.layer}_${body.owner ?? "none"}_${body.key}`; - state.server[key] = { - info: { 폴더: key, 원본: body, 원본판: original.version, 가져온날: "2026-09-29" }, - parts: { - 도면: JSON.parse(JSON.stringify(original.docs.structure)), - 면적: JSON.parse(JSON.stringify(original.docs.area)), - 재료: JSON.parse(JSON.stringify(original.docs.material)), - }, - }; - return reply(200, state.server[key].info); - } - const item = state.server[decodeURIComponent(folder)]; - if (!item) return reply(404, { detail: "없는 폴더" }); - if (part === "refresh" && method === "POST") { - item.parts.면적 = JSON.parse(JSON.stringify(original.docs.area)); - item.info.원본판 = original.version; - return reply(200, item.info); - } - if (method === "GET") { - const doc = item.parts[decodeURIComponent(part)]; - return doc === undefined ? reply(404, { detail: "부분 없음" }) : reply(200, doc); - } - if (method === "PUT") { - if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); - item.parts[decodeURIComponent(part)] = body; - return reply(200, { ok: true }); - } - } - const inputs = /^\/api\/m02\/layers\/(\w+)\/items\/([^/]+)\/inputs/.exec(url); - if (inputs) { - return state.inputsRoute - ? reply(200, { 일위대가: "UAREAL", 이름: "실서버", 로직: [], 빠진로직: [] }) - : reply(404, { detail: "Not Found" }); - } - if (url.startsWith("/api/m01/combo?key=")) { - return reply(200, { - combo: { 이름: "기슭막이", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }, { 로직: "GX" }] }, - }); - } - if (url.startsWith("/api/m01/logic/auto?key=")) { - return reply(200, { 값: { 지역: "전국평균", 소량감률: 0 } }); - } - const logic = /^\/api\/m01\/logic\?key=(\w+)/.exec(url); - if (logic) { - if (logic[1] === "GX") return reply(404, { detail: "없는 로직" }); - return reply(200, { - logic: { - 이름: `로직 ${logic[1]}`, - 결과단위: "원/m", - 입력: [ - { 이름: "지역", 고르기: ["전국평균", "서울"] }, - { 이름: "소량감률", 단위: "%", 범위: [0, 50] }, - ], - }, - }); + if (method === "PUT") { + if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); + const current = versionOf(item.parts[part] ?? null); + if (body.판 !== current) + return reply(409, { detail: { message: "다른 곳에서 먼저 고쳤습니다", 판: current } }); + item.parts[part] = body.내용; + return reply(200, { 종류: part, 판: versionOf(body.내용) }); } return reply(404, { detail: "Not Found" }); }; const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts")); -const card = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_InputsCard.ts")); +const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); -const row = (over = {}) => ({ - key: "SS000001", - 열: "rv", - layer: "system", - owner: null, - 일위대가: "UA1", - ...over, -}); -const clone = (x) => JSON.parse(JSON.stringify(x)); +const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over }); async function main() { const out = {}; - - // ① 폴더 이름 — 서버 `snapshot_folder_name` 과 같은 글 out.folders = [ project.folderOf(row()), - project.folderOf(row({ layer: "company", owner: "3", key: "SS000001" })), + project.folderOf(row({ layer: "company", owner: "3" })), project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), ]; - // ② 가짜 복사본 — 서버 길이 없을 때 - state.mode = "fake"; - const fake = project.projectItems("P-FAKE"); - const before = originalSnapshot(); - out.fake = { list: await fake.list() }; - out.fake.isFake = fake.isFake(); - const info = await fake.importItem(row()); - out.fake.info = info; - out.fake.readBack = await fake.read(info.폴더, "면적"); - await fake.write(info.폴더, "면적", { - ...out.fake.readBack, - 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }], - }); - await fake.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); - out.fake.afterWrite = await fake.read(info.폴더, "면적"); - out.fake.listAfter = (await fake.list()).map((one) => one.폴더); - out.fake.changedNow = await fake.masterChanged(row(), info); - original.version = "v2"; - out.fake.changedAfterMaster = await fake.masterChanged(row(), info); - await fake.refresh(row(), info); - out.fake.afterRefresh = await fake.read(info.폴더, "면적"); - out.fake.valuesKept = await fake.read(info.폴더, "입력값"); - out.fake.changedAfterRefresh = await fake.masterChanged(row(), (await fake.list())[0]); - out.fake.originalUntouched = before.replace('"v1"', '"v2"') === originalSnapshot(); - out.fake.storageKeys = [...store.keys()]; - await fake.write("없는폴더", "면적", {}).then( - () => (out.fake.writeMissing = "성공"), - (error) => (out.fake.writeMissing = error.message), + const items = project.projectItems("P1"); + out.listEmpty = await items.list(); + const info = await items.importItem(row({ layer: "personal", owner: "1/3" })); + out.info = info; + out.importCall = state.calls.find((c) => c.method === "POST"); + out.list = (await items.list()).map((x) => x.폴더); + + out.read = await items.read(info.폴더, "면적"); + out.readNullContent = await items.read(info.폴더, "입력값"); // 내용 null = 아직 없음 + out.readMissing = await items.read("personal_9-9_SS000009", "면적"); // 없는 폴더 404 = null + await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }] }); + out.afterWrite = state.folders[info.폴더].parts.면적; + // 판: 읽은 판으로 씀 · 쓴 뒤 새 판을 들고 있어 이어서 또 쓸 수 있음 + await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 999 } } }] }); + out.secondWrite = state.folders[info.폴더].parts.면적.시트[0].칸.H10; + // 읽은 적 없는 부분도 쓰기 전에 판을 먼저 앎 + const getsBefore = state.calls.filter((c) => c.method === "GET").length; + await items.write(info.폴더, "고른자재", { GA1: { 0: "MT000001" } }); + out.writeWithoutReadGets = state.calls.filter((c) => c.method === "GET").length - getsBefore; + await items.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); + out.valuesSaved = state.folders[info.폴더].parts.입력값; + + // 다른 곳에서 먼저 고침(판이 달라짐) → 409 까닭(`message`) 그대로 + state.folders[info.폴더].parts.면적 = { 시트: [], 남이: true }; + await items.write(info.폴더, "면적", {}).then( + () => (out.stale = "성공"), + (error) => (out.stale = error.message), ); - original.version = "v1"; - // ③ 실서버 모양 — 약속 길 - state.mode = "real"; - state.calls = []; - const real = project.projectItems("P-REAL"); - out.real = { list: await real.list(), isFake: real.isFake() }; - const rinfo = await real.importItem(row({ layer: "personal", owner: "1/3" })); - out.real.info = rinfo; - out.real.importCall = state.calls.find((c) => c.method === "POST"); - out.real.read = await real.read(rinfo.폴더, "면적"); - out.real.readMissing = await real.read(rinfo.폴더, "고른자재"); - await real.write(rinfo.폴더, "입력값", { L: { 이름: { 값: 1, 확인: true } } }); - out.real.putCall = state.calls.filter((c) => c.method === "PUT").map((c) => c.url); state.readOnly = true; - await real.write(rinfo.폴더, "면적", {}).then( - () => (out.real.readOnlyWrite = "성공"), - (error) => (out.real.readOnlyWrite = error.message), + await items.write(info.폴더, "입력값", {}).then( + () => (out.readOnly = "성공"), + (error) => (out.readOnly = error.message), ); state.readOnly = false; - await real.refresh(row({ layer: "personal", owner: "1/3" }), rinfo); - out.real.refreshCall = state.calls + + const fresh = await items.refresh(info); + out.refresh = fresh; + out.refreshCall = state.calls .filter((c) => /refresh$/.test(c.url)) .map((c) => `${c.method} ${c.url}`); - out.real.changedFlag = await real.masterChanged(row(), { ...rinfo, 마스터바뀜: true }); + out.calc = await items.calc(info.폴더); + out.calcMissing = await items.calc("없는폴더"); - // ④ 입력 목록 - state.inputsRoute = false; - const madeUp = await project.loadInputs(row({ key: "SS000009" })); - out.inputs = { - fake: madeUp.fake, - logics: madeUp.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]), - missing: madeUp.list.빠진로직, - auto: madeUp.list.로직[0].입력.map((i) => i.자동값), - link: project.fakeLink(madeUp.list).줄.length, - linkPlaces: [...new Set(project.fakeLink(madeUp.list).줄.map((r) => r.자리))], - }; - state.inputsRoute = true; - const realInputs = await project.loadInputs(row({ key: "SS000010" })); - out.inputs.real = { fake: realInputs.fake, name: realInputs.list.이름 }; + // 입력 목록 — 같은 항목은 한 번만 받음 + const first = await project.loadInputs(row()); + await project.loadInputs(row()); + out.inputsHits = state.inputsHits; + out.inputsLogics = first.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]); - // ⑤ 카드 순수 함수 - out.card = { + // 카드 셈 + out.model = { range: [ - card.rangeOf({ 이름: "a", 범위: [0, 50] }), - card.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), - card.rangeOf({ 이름: "c" }), + model.rangeOf({ 이름: "a", 범위: [0, 50] }), + model.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), + model.rangeOf({ 이름: "c" }), ], - choices: card.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), - keys: [card.inputKey("GA1", "지역"), card.pillKey("GA1", "지역")], - groups: card - .pickedGroups(madeUp.list, { + choices: model.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), + groups: model + .pickedGroups(first, { 판: "x", 줄: [ { 로직: "GA1", 입력: "지역", 자리: "선택" }, { 로직: "GA1", 입력: "소량감률", 자리: "면적" }, - { 로직: "GA2", 입력: "소량감률", 자리: "선택" }, + { 로직: "GA2", 입력: "지역", 자리: "선택" }, ], }) .map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]), - noLink: card.pickedGroups(madeUp.list, null).length, + noLink: model.pickedGroups(first, null).length, + pills: model.pillsOf( + first, + { 판: "x", 줄: [{ 로직: "GA1", 입력: "소량감률", 자리: "면적" }] }, + "면적", + ), }; return out; } diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 7becafeb..63956516 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""항목 고르기 모달 상세(계획서 25-2d) — 자료 창구 · 선택 입력 카드 · 한 벌 지킴. +"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴. -① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(가짜 서버 fetch 대역) 본다: - 서버 길이 없을 때의 가짜 복사본(가져오기 · 고침 · 마스터가 바뀜 · 새로 가져오기 · **원본 그대로**) · - 약속 길(목록 · 읽기 · PUT · 403 까닭 그대로 · 가져오기 · 새로 가져오기) · 입력 목록(약속 길 · M01 로 짜 맞춤). +① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역) + 본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) · + 새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」). ② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다. """ @@ -43,75 +43,62 @@ def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): {"layer": "personal", "owner": "3/12", "key": "SS000002"}, ] assert out["folders"] == [snapshot_folder_name(row) for row in rows] + assert out["info"]["폴더"] == snapshot_folder_name( + {"layer": "personal", "owner": "1/3", "key": "SS000001"} + ) -def test_가짜_복사본_가져와_고치고_다시_읽으면_고친_값(out): - fake = out["fake"] - assert fake["isFake"] is True and fake["list"] == [] # 서버 길이 없음 → 가짜 - assert fake["info"]["폴더"] == "system_none_SS000001" - assert fake["readBack"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 가져온 그대로 - assert fake["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} # 고침이 남음 - assert fake["listAfter"] == ["system_none_SS000001"] - - -def test_가짜_복사본을_고쳐도_원본은_그대로(out): - assert out["fake"]["originalUntouched"] is True - assert out["fake"]["storageKeys"] == ["aislo.itemsFake.P-FAKE"] # 브라우저 저장소 한 칸만 - - -def test_마스터가_바뀌면_표시_새로_가져오면_원본으로_덮고_넣은_값은_남김(out): - fake = out["fake"] - assert fake["changedNow"] is False - assert fake["changedAfterMaster"] is True - assert fake["afterRefresh"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 고침이 원본으로 덮임 - assert fake["valuesKept"]["GA1"]["지역"] == { - "값": "서울", - "확인": True, - } # 사용자가 넣은 값은 남음 - assert fake["changedAfterRefresh"] is False - - -def test_안_가져온_항목에_쓰면_까닭을_던짐(out): - assert out["fake"]["writeMissing"] == "프로젝트에 가져오지 않은 항목" - - -def test_약속_길_목록_읽기_고침_가져오기_새로_가져오기(out): - real = out["real"] - assert real["isFake"] is False # 길이 있으면 가짜를 안 탐 - assert real["importCall"] == { +def test_가져오기는_POST_원본_참조_목록은_항목(out): + assert out["listEmpty"] == [] + assert out["importCall"] == { "method": "POST", - "url": "/api/projects/P-REAL/items", + "url": "/api/projects/P1/items", "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, } - assert real["readMissing"] is None # 부분이 없음 = null - assert len(real["putCall"]) == 1 and real["putCall"][0].endswith("/%EC%9E%85%EB%A0%A5%EA%B0%92") - assert real["refreshCall"][0].startswith("POST /api/projects/P-REAL/items/") - assert real["refreshCall"][0].endswith("/refresh") - assert real["changedFlag"] is True # 실서버는 `마스터바뀜` 을 그대로 씀 + assert out["list"] == [out["info"]["폴더"]] -def test_권한_없음은_서버_까닭_그대로(out): - assert out["real"]["readOnlyWrite"] == "프로젝트 편집 권한이 없음" +def test_읽기는_내용만_돌려주고_없으면_null(out): + assert out["read"]["시트"][0]["칸"] == {"A1": {"값": 1}} + assert out["readNullContent"] is None # 파일이 아직 없음(내용 null) + assert out["readMissing"] is None # 없는 폴더 404 -def test_입력_목록_약속_길이_있으면_그대로_없으면_M01_로_짜_맞춤(out): - inputs = out["inputs"] - assert inputs["real"] == {"fake": False, "name": "실서버"} - assert inputs["fake"] is True - # 로직마다 갈라 — 이름이 같아도 합치지 않음 · 없는 로직은 빠진로직 - assert inputs["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역", "소량감률"]]] - assert inputs["missing"] == ["GX"] - assert inputs["auto"] == ["전국평균", 0] - assert inputs["link"] == 4 and inputs["linkPlaces"] == ["선택"] # 가짜 연결 = 모두 선택 +def test_고침은_읽은_판으로_쓰고_쓴_뒤_새_판을_들고_있음(out): + assert out["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} + assert out["secondWrite"] == {"값": 999} # 이어서 또 써도 판이 맞음 + assert out["writeWithoutReadGets"] == 1 # 읽은 적 없는 부분은 쓰기 전에 판을 한 번 읽음 + assert out["valuesSaved"]["GA1"]["지역"] == {"값": "서울", "확인": True} -def test_선택_입력_카드_순수_함수(out): - card = out["card"] - assert card["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] - assert [c["raw"] for c in card["choices"]] == [15, "16", 18] # 저장은 원래 모양 - assert card["keys"] == ["GA1|지역", "입력|GA1|지역"] # 25-1b 키 모양 - assert card["groups"] == [["GA1", ["지역"]], ["GA2", ["소량감률"]]] # 자리 「선택」 만 - assert card["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 +def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로(out): + assert out["stale"] == "다른 곳에서 먼저 고쳤습니다" + assert out["readOnly"] == "프로젝트 편집 권한이 없음" + + +def test_새로_가져오기와_계산_한_줄(out): + assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/") + assert out["refreshCall"][0].endswith("/refresh") + assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판" + assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} + assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 + + +def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out): + assert out["inputsHits"] == 1 + assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] + + +def test_선택_입력_카드_셈(out): + model = out["model"] + assert model["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] + assert [c["raw"] for c in model["choices"]] == [15, "16", 18] # 저장은 원래 모양 + assert model["groups"] == [ + ["GA1", ["지역"]], + ["GA2", ["지역"]], + ] # 같은 이름도 로직마다 · 「선택」 만 + assert model["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 + assert model["pills"][0]["키"] == "입력|GA1|소량감률" # 25-1b 알약 키 def test_모달은_M02_와_같은_카드_한_벌을_씀(): @@ -128,6 +115,17 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): assert cards.count("mountDrawingTemplate(") == 1 +def test_선택_입력_카드는_sub2_파일_한_벌(out): + """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() + inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) + assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): + text = (UI / name).read_text(encoding="utf-8") + assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name + + def test_모달은_원본_층에_쓰는_길을_안_부름(): """마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐.""" for name in ( @@ -141,6 +139,7 @@ def test_모달은_원본_층에_쓰는_길을_안_부름(): "deleteTemplate(", "deleteLayerTemplate(", "copyToPersonal(", + "saveInputLinks(", ): assert forbidden not in text, f"{name} 이 {forbidden} 를 부름" edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8") @@ -161,6 +160,7 @@ def test_소스가_700줄을_넘지_않음(): *sorted(UI.glob("ui_template_item_picker*.ts")), M02 / "M02_MasterTemplete_ItemCards.ts", M02 / "M02_MasterTemplete_ItemSource.ts", - M02 / "M02_MasterTemplete_InputsCard.ts", + M02 / "M02_MasterTemplete_Inputs.ts", + M02 / "M02_MasterTemplete_Inputs_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index f7f1099e..7abeeda4 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,7 +1,7 @@ /* ============================================================================= * ui_template_item_picker_detail.ts * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · - * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀 + * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). @@ -10,14 +10,15 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { + readInputLinks, + readTemplate, + type InputLinks, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import type { - InputsCardHandle, - LinkDoc, - ValueDoc, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; import { folderLines, itemName, @@ -28,7 +29,6 @@ import { type PickerRow, } from "./ui_template_item_picker_data"; import { - fakeLink, folderOf, loadInputs, PART_OF, @@ -72,7 +72,8 @@ const TEXT = { importFailed: "프로젝트로 가져오지 못함 — {value}", refreshFailed: "새로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", - fake: "임시 저장(서버 길 준비 중)", + calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", + calcStuck: " · 멈춘 로직 {stuck}개", loading: "읽는 중…", noCopy: "복사본에 없음", combo: "자재조합", @@ -81,7 +82,7 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -const dateOf = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : ""); export function createItemDetail(opts: ItemDetailOptions): ItemDetail { const root = el("div", { className: "ui-item-picker__detail" }); @@ -94,7 +95,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let seq = 0; let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; - let inputs: InputsCardHandle | null = null; + let inputs: InputsValueHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; let info: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; @@ -149,11 +150,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); - if (items.isFake()) - nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake })); - const changed = await items.masterChanged(row, info).catch(() => false); - if (mine !== seq) return; - if (changed) { + if (info.마스터바뀜) { const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); button.addEventListener("click", () => void refresh(row)); nodes.push( @@ -164,13 +161,29 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { } status.replaceChildren(...nodes); status.hidden = false; + if (info) void paintCalc(info, mine); }; + /** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */ + async function paintCalc(from: ProjectItemInfo, mine: number): Promise { + const got = await items?.calc(from.폴더).catch(() => null); + if (!got || mine !== seq) return; + const total = Math.round(got.total).toLocaleString(); + status.append( + el("span", { + className: "ui-item-picker__calc", + text: + TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) + + (got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""), + }), + ); + } + async function refresh(row: PickerRow): Promise { if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; try { - await items.refresh(row, info); - infos = null; + const fresh = await items.refresh(info); + infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one)); show(row); } catch (error) { showToast(fill(TEXT.refreshFailed, why(error)), "error"); @@ -184,7 +197,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (!info) { try { info = await items.importItem(row); - infos = null; + infos = [...(infos ?? []), info]; } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; @@ -196,12 +209,15 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { async function paintInputs(row: PickerRow, mine: number): Promise { if (!inputs) return; try { - const { list, fake } = await loadInputs(row); - let link: LinkDoc | null = fake ? fakeLink(list) : null; + const list = await loadInputs(row); + let link: InputLinks | null; let values: ValueDoc = {}; if (info && items) { - link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link; + // 복사본 것(가져온 때 그대로) — 값도 복사본에서 + link = (await items.read(info.폴더, "입력연결")) as InputLinks | null; values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + } else { + link = await readInputLinks(layerOf(row), row.key).catch(() => null); } if (mine === seq) inputs.set({ list, link, values }); } catch (error) { @@ -213,9 +229,9 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (cards && inputs) return; const [cardModule, inputModule] = await Promise.all([ import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), ]); - inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") }); + inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, editable: () => !!items, diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index e9a89b1e..abe7996c 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -11,15 +11,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_el import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; -import type { - InputsCardHandle, - LinkDoc, - ValueDoc, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; -import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditRequest } from "./ui_template_item_picker_detail"; import { itemName } from "./ui_template_item_picker_data"; -import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project"; +import { loadInputs, PART_OF } from "./ui_template_item_picker_project"; export interface EditView { root: HTMLElement; @@ -39,7 +36,8 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸", + noAreaPill: + "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)", drawing: "도면", area: "면적산출", material: "재료산출", @@ -57,7 +55,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { let doc: unknown = null; let drawing: DrawingTemplateHandle | null = null; let sheet: SpreadsheetHandle | null = null; - let card: InputsCardHandle | null = null; + let card: InputsValueHandle | null = null; const word = { structure: TEXT.drawing, @@ -97,36 +95,29 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { body.replaceChildren(el("p", { className: "ui-item-picker__hint", text })); }; - /** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */ - async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> { - const { list, fake } = await loadInputs(row); - const link = - ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? - (fake ? fakeLink(list) : null); - const 항목 = (link?.줄 ?? []) - .filter((one) => one.자리 === "면적") - .map((one) => ({ - 키: one.알약 ?? `입력|${one.로직}|${one.입력}`, - 이름: one.입력, - 단위: - list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력) - ?.단위 ?? "", - })); - return 항목.length - ? { links: { 항목, 모드: "입력" } } - : { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill }; + /** 복사본의 항목 연결 — 자리가 면적 · 재료인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */ + const linkOf = async (): Promise => + (await items.read(info.폴더, "입력연결")) as InputLinks | null; + + async function sheetLinks(kindOf: "area" | "material"): Promise { + const [list, link, { searchMaterials }] = await Promise.all([ + loadInputs(row), + linkOf(), + import("../M01_MasterData/M01_MasterData_Api_Fetch"), + ]); + const 항목 = pillsOf(list, link, kindOf === "area" ? "면적" : "재료"); + if (kindOf === "area") { + if (!항목.length) note.textContent = TEXT.noAreaPill; + return { 항목, 모드: "입력" }; + } + return { 항목, 모드: "자재", 자재찾기: searchMaterials }; } async function mountSheet(kindOf: "area" | "material"): Promise { - const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([ + const [{ createSpreadsheet }, links] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), - kindOf === "area" - ? areaLinks() - : Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }), - import("../M01_MasterData/M01_MasterData_Api_Fetch"), + sheetLinks(kindOf), ]); - const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials }; - if (pills.notice) note.textContent = pills.notice; const host = el("div", { className: "ui-item-picker__edit-sheet" }); body.replaceChildren(host); sheet = createSpreadsheet(host, doc as Workbook, { @@ -155,16 +146,14 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { } async function mountInputs(): Promise { - const [{ createInputsCard }, loaded] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + const [{ createInputsValueCard }, list, link] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), loadInputs(row), + linkOf(), ]); - const link = - ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? - (loaded.fake ? fakeLink(loaded.list) : null); const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; - card = createInputsCard({ mode: "value", onChange: () => setDirty(true) }); - card.set({ list: loaded.list, link, values }); + card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) }); + card.set({ list, link, values }); body.replaceChildren( el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), ); diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index d06fd77f..7275b48a 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -1,26 +1,21 @@ /* ============================================================================= * ui_template_item_picker_project.ts - * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름. + * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * - * 프로젝트 복사본 GET /api/projects/{id}/items 목록 - * GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속) - * POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조) - * POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기 - * 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a) - * - * ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함: - * 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 · - * 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값). - * 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음. + * 프로젝트 복사본(sub6 · 25-2b) + * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]} + * POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) + * POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림) + * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) + * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import type { - InputsList, - LinkDoc, - LogicInput, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { + fetchItemInputs, + type ItemInputs, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ @@ -36,27 +31,49 @@ export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = { export interface ProjectItemInfo { 폴더: string; + 이름?: string | null; 원본: { layer: string; owner: string | null; key: string; name?: string }; - 원본판?: string; - 가져온날?: string; + 원본판?: string | null; + 가져온날?: string | null; /** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */ 마스터바뀜?: boolean; + 원본없음?: boolean; + 구조물?: string[]; + 고침?: boolean; } /** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ export const folderOf = (row: PickerRow): string => `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; +/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ +export interface ProjectCalc { + total: number; + gaps: number; + stuck: number; +} + interface Reply { status: number; body: unknown; } const enc = encodeURIComponent; -const reasonOf = (reply: Reply): string => { + +/** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */ +function reasonOf(reply: Reply): string { const detail = (reply.body as { detail?: unknown } | null)?.detail; - return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`; -}; + if (typeof detail === "string" && detail) return detail; + const message = (detail as { message?: unknown } | null)?.message; + if (typeof message === "string" && message) return message; + if (Array.isArray(detail)) { + const lines = detail + .map((one) => String((one as { msg?: unknown })?.msg ?? "")) + .filter(Boolean); + if (lines.length) return lines.join(" · "); + } + return `HTTP ${reply.status}`; +} async function call(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -67,224 +84,105 @@ async function call(path: string, init: RequestInit = {}): Promise { return { status: response.status, body: await response.json().catch(() => null) }; } -/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */ -const routeMissing = (reply: Reply): boolean => - reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found"; - -const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); - -/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */ -interface FakeItem { - info: ProjectItemInfo; - parts: Partial>; -} -type FakeDb = Record; -const memory = new Map(); -const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`; - -function fakeLoad(projectId: string): FakeDb { - const kept = memory.get(projectId); - if (kept) return kept; - try { - const raw = localStorage.getItem(storeKey(projectId)); - const db = raw ? (JSON.parse(raw) as FakeDb) : {}; - memory.set(projectId, db); - return db; - } catch { - const db: FakeDb = {}; - memory.set(projectId, db); - return db; - } -} - -function fakeSave(projectId: string): void { - try { - localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId))); - } catch { - // 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨 - } -} - -/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */ -async function readOriginals( - row: PickerRow, -): Promise<{ parts: FakeItem["parts"]; version: string }> { - const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"]; - const got = await Promise.all( - kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)), - ); - const parts: FakeItem["parts"] = {}; - kinds.forEach((kind, index) => { - if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서); - }); - return { parts, version: got.map((one) => one?.판 ?? "").join("|") }; -} +const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); export interface ProjectItems { - /** 프로젝트에 가져온 항목 목록 */ + /** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */ list: () => Promise; - /** 복사본 부분 하나 — 없으면 null */ + /** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */ read: (folder: string, part: ProjectPart) => Promise; - /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */ + /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ importItem: (row: PickerRow) => Promise; - /** 새로 가져옴 — 복사본을 원본으로 덮음 */ - refresh: (row: PickerRow, info: ProjectItemInfo) => Promise; - /** 가져온 뒤 원본이 바뀌었나 */ - masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise; - /** 가짜로 동작 중인가(서버 길이 아직 없음) */ - isFake: () => boolean; + /** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */ + refresh: (info: ProjectItemInfo) => Promise; + /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ + calc: (folder: string) => Promise; } const clients = new Map(); -/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */ +/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */ export function projectItems(projectId: string): ProjectItems { const kept = clients.get(projectId); if (kept) return kept; - let fake = false; const base = `/projects/${enc(projectId)}/items`; + const versions = new Map(); + const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; const client: ProjectItems = { - isFake: () => fake, list: async () => { - if (!fake) { - const reply = await call(base); - if (reply.status === 200 && Array.isArray(reply.body)) - return reply.body as ProjectItemInfo[]; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - fake = true; // FAKE — 서버 길이 아직 없음 - } - return Object.values(fakeLoad(projectId)).map((item) => item.info); + const reply = await call(base); + const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목; + if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply)); + return rows; }, read: async (folder, part) => { - if (!fake) { - const reply = await call(`${base}/${enc(folder)}/${enc(part)}`); - if (reply.status === 200) return reply.body; - if (reply.status === 404) return null; - throw new Error(reasonOf(reply)); - } - return fakeLoad(projectId)[folder]?.parts[part] ?? null; + const reply = await call(at(folder, part)); + if (reply.status === 404) return null; + if (reply.status !== 200) throw new Error(reasonOf(reply)); + const got = reply.body as { 판?: string | null; 내용?: unknown }; + versions.set(`${folder}/${part}`, got.판 ?? null); + return got.내용 ?? null; }, write: async (folder, part, doc) => { - if (!fake) { - const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, { - method: "PUT", - body: JSON.stringify(doc), - }); - if (reply.status >= 400) throw new Error(reasonOf(reply)); - return; - } - const item = fakeLoad(projectId)[folder]; - if (!item) throw new Error("프로젝트에 가져오지 않은 항목"); - item.parts[part] = structuredClone(doc); - fakeSave(projectId); + const key = `${folder}/${part}`; + if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎 + const reply = await call(at(folder, part), { + method: "PUT", + body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }), + }); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, importItem: async (row) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; - if (!fake) { - const reply = await call(base, send(ref)); - if (reply.status < 300) return reply.body as ProjectItemInfo; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - fake = true; // FAKE - } - const { parts, version } = await readOriginals(row); - const info: ProjectItemInfo = { - 폴더: folderOf(row), - 원본: { ...ref, name: row.종류 }, - 원본판: version, - 가져온날: new Date().toISOString(), + const reply = await call(base, post(ref)); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + const got = reply.body as { 폴더: string; 가져온날?: string | null }; + return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; + }, + refresh: async (info) => { + const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({})); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + for (const key of [...versions.keys()]) + if (key.startsWith(`${info.폴더}/`)) versions.delete(key); + inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음 + const got = reply.body as { 가져온날?: string | null; 원본판?: string | null }; + return { + ...info, + 가져온날: got.가져온날, + 원본판: got.원본판, 마스터바뀜: false, + 고침: false, }; - fakeLoad(projectId)[info.폴더] = { info, parts }; - fakeSave(projectId); - return info; }, - refresh: async (row, info) => { - if (!fake) { - const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({})); - if (reply.status >= 400) throw new Error(reasonOf(reply)); - return reply.body as ProjectItemInfo; - } - // 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로 - const kept = fakeLoad(projectId)[info.폴더]?.parts; - const fresh = await client.importItem(row); - const item = fakeLoad(projectId)[fresh.폴더]; - for (const part of ["입력값", "고른자재"] as const) - if (kept?.[part] !== undefined && item) item.parts[part] = kept[part]; - fakeSave(projectId); - return fresh; - }, - masterChanged: async (row, info) => { - if (!fake) return info.마스터바뀜 === true; - return (await readOriginals(row)).version !== (info.원본판 ?? ""); + calc: async (folder) => { + const reply = await call(`${base}/${enc(folder)}/calc`, post({})); + if (reply.status !== 200) return null; + const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] }; + return { + total: Number(got.계 ?? 0), + gaps: got.빠진입력?.length ?? 0, + stuck: got.멈춤?.length ?? 0, + }; }, }; clients.set(projectId, client); return client; } -/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */ -const inputsCache = new Map>(); +/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */ +const inputsCache = new Map>(); -async function fakeInputs(row: PickerRow): Promise { - const combo = row.일위대가 ?? ""; - const got = await call(`/m01/combo?key=${enc(combo)}`); - const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null) - ?.combo; - if (got.status !== 200 || !header) throw new Error(reasonOf(got)); - const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] }; - for (const one of header.담은로직 ?? []) { - const [logic, auto] = await Promise.all([ - call(`/m01/logic?key=${enc(one.로직)}`), - call(`/m01/logic/auto?key=${enc(one.로직)}`), - ]); - const row = ( - logic.body as { - logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] }; - } | null - )?.logic; - if (logic.status !== 200 || !row) { - list.빠진로직.push(one.로직); - continue; - } - const values = (auto.body as { 값?: Record } | null)?.값 ?? {}; - list.로직.push({ - 로직: one.로직, - 이름: row.이름 ?? one.로직, - 결과단위: row.결과단위 ?? null, - 입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })), - }); - } - return list; -} - -/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */ -export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> { +/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */ +export function loadInputs(row: PickerRow): Promise { const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; const kept = inputsCache.get(id); if (kept) return kept; - const made = (async () => { - const owner = ownerOf(row); - const reply = await call( - `/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`, - ); - if (reply.status === 200) return { list: reply.body as InputsList, fake: false }; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - return { list: await fakeInputs(row), fake: true }; // FAKE - })(); + const made = fetchItemInputs(layerOf(row), row.key); inputsCache.set(id, made); - made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시 + made.catch(() => inputsCache.delete(id)); return made; } - -/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */ -export function fakeLink(list: InputsList): LinkDoc { - return { - 판: "fake", - 줄: list.로직.flatMap((logic) => - logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), - ), - }; -} From 818470e25f1c359cba74097ba1fcc0fa4a28ca53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 00:14:58 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(M02):=20=EC=B9=B4=EB=93=9C=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EA=B8=80(=EB=8F=84=EB=A9=B4=20=EC=97=86=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=E2=80=A6)=20=EA=B8=80=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EB=B9=84=204.5=20=EC=9D=B4=EC=83=81=20(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M02_MasterTemplete/M02_MasterTemplete_Structure.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index de26589e..864c2011 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -112,7 +112,7 @@ .m02-struct__empty { margin: 0; padding: var(--spacing-16); - color: var(--color-text-muted); + color: var(--color-text-secondary); } /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */