diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 9e4b82ff8..e900d37a8 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -10,20 +10,43 @@ 대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · 단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · [저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`). + +운반 값 사본(263-2) — 운반공 줄 연결 + `{tab: dg, key, lib, given{줄 id: {입력: 값}}, own[줄 id|입력]}` · + 담은 로직 줄(id = `{참조 id}#{차례}` · 맨 위 줄 = `#{차례}`)의 거리 칸 + (운반거리 · Cm · 사이클시간 · t2) = + 거리이정표 거리로 셈(짝 `B08_Quantity_Haul_Cycle`) · 그 밖 = 손 값 → 값 묶음 `값` · + 폴더 = `{종류}@{층}@{주인}@{키}~{값 해시 8}`(값 없으면 해시 없음 · 옛 이름 그대로 읽힘) · + 같은 항목 같은 값 = 같은 사본 · + manifest `원값` = 덮기 전 값(다른 값 사본을 이 사본에서 다시 만듦) · + 단가 묶음 = 로직 · 자재 키로만 모음 → 값이 바뀌어도 그대로 · + 셈 때(`resolve_hauls`) 서버가 거리로 값을 다시 셈 → 다른 해시면 새 사본 · + 줄 연결 lib · key 바꿔 셈. """ from __future__ import annotations +import hashlib +import json import re import shutil from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_inputs, cycle_time from B09_Estimation.B09_Estimation_Store import folder as store_folder from B09_Estimation.B09_Estimation_Store import ref_rows from common_util.common_util_json import atomic_write_json +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of +from M02_MasterTemplete.M02_Item_Snapshot import ( + MANIFEST_NAME, + VALUES_FILE, + build_snapshot, + now_text, + origin_of, + seed_values, +) from M02_MasterTemplete.M02_Item_Source import ( LABOR_FILE, PRICE_FILE, @@ -35,17 +58,28 @@ from M02_MasterTemplete.M02_Item_Source import ( LIBRARY = "library" KINDS = ("combo", "precombo") -#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) -NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$") +#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음) +NAME = re.compile( + r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" +) def library_dir(project_root: str | Path) -> Path: return store_folder(project_root) / LIBRARY -def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str: - """고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError.""" +def tag(values: dict[str, Any]) -> str: + """값 묶음 해시 8 — 같은 값 = 같은 표지.""" + text = json.dumps(values, sort_keys=True, ensure_ascii=False) + return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8] + + +def folder_name( + kind: str, layer: str, owner: str | None, key: str, values: dict[str, Any] | None = None +) -> str: + """고른 서고 것 → 사본 폴더 이름(값 있으면 `~해시`) — 규칙 밖이면 ValueError.""" name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}" + name += f"~{tag(values)}" if values else "" if not NAME.match(name): raise ValueError(f"서고 항목 모양이 틀림 「{name}」") return name @@ -65,6 +99,130 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: } +def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]: + """서고 원본 → (사본 파일들, 원본 표지).""" + if kind not in KINDS: + raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") + folder_name(kind, layer, owner, key) # 모양 먼저(원본 읽기 전) + if kind == "combo": + item = {"layer": layer, "owner": owner, "key": key} + item["name"] = item_name(item) + return build_snapshot(item), origin_of(item) + files = _precombo_files(layer, owner, key) + return files, files.pop("_원본") + + +# ── 운반 값 사본(263-2) ──────────────────────────────────────────────── +def _logic(code: str) -> dict[str, Any]: + try: + return dict(m01.loaded()[1].logic(code) or {}) + except Exception: # noqa: BLE001 — 없는 로직 = 입력 없음 + return {} + + +def _line_at(labor: Any, lid: str) -> dict[str, Any] | None: + rid, _, at = lid.rpartition("#") + doc = (labor or {}).get("줄") if not rid else ((labor or {}).get("참조사본") or {}).get(rid) + lines = doc.get("담은로직") if isinstance(doc, dict) else None + if not at.isdigit() or not isinstance(lines, list) or int(at) >= len(lines): + return None + line = lines[int(at)] + return line if isinstance(line, dict) and line.get("로직") else None + + +def lines_of(files: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """사본 안 로직 줄 `(줄 id, 줄)` — 맨 위 줄 → 굳힌 참조 문서 줄.""" + labor = files.get(LABOR_FILE) or {} + docs = [("", labor.get("줄")), *((labor.get("참조사본") or {}).items())] + return [ + (f"{rid}#{i}", one) + for rid, doc in docs + for i, one in enumerate((doc or {}).get("담은로직") or []) + if isinstance(one, dict) and one.get("로직") + ] + + +def haul_values( + lines: list[tuple[str, dict[str, Any]]], given: Any, own: Any, dist: Any +) -> dict[str, dict[str, Any]]: + """값 묶음 `{줄 id: {입력: 값}}` — 거리 칸 = 거리로 셈(`own` 이면 손 값) · 그 밖 = 손 값.""" + given = given if isinstance(given, dict) else {} + own = set(own if isinstance(own, list) else []) + got = cycle_time(cycle_inputs(None, dist)) if isinstance(dist, dict) else None + out: dict[str, dict[str, Any]] = {} + for lid, line in lines: + hand = given.get(lid) if isinstance(given.get(lid), dict) else {} + vals = {} + for name in (str(i.get("이름")) for i in _logic(str(line["로직"])).get("입력") or []): + if got and name in FILL and f"{lid}|{name}" not in own: + vals[name] = got[FILL[name]] + elif isinstance(hand.get(name), (str, int, float)): + vals[name] = hand[name] + if vals: + out[lid] = vals + return out + + +def _apply(files: dict[str, Any], kind: str, values: dict[str, Any]) -> dict[str, Any]: + """값 덮기 → 덮기 전 값(`원값` · 없던 칸 = None).""" + labor = files.get(LABOR_FILE) + old: dict[str, Any] = {} + for lid, vals in values.items(): + if (line := _line_at(labor, lid)) is None: + continue + given = dict(line.get("입력값") or {}) + old[lid] = {k: given.get(k) for k in vals} + line["입력값"] = {**given, **vals} + if kind == "combo" and old: + files[VALUES_FILE] = seed_values(labor) + return old + + +def _restore(files: dict[str, Any], kind: str, old: Any) -> None: + """`원값` 되돌림 — 덮기 전 사본 내용.""" + labor = files.get(LABOR_FILE) + for lid, vals in (old if isinstance(old, dict) else {}).items(): + if (line := _line_at(labor, lid)) is None: + continue + given = dict(line.get("입력값") or {}) + for k, v in vals.items(): + if v is None: + given.pop(k, None) + else: + given[k] = v + line["입력값"] = given + if kind == "combo" and old: + files[VALUES_FILE] = seed_values(labor) + + +def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None: + target = library_dir(project_root) / name + old = _apply(files, str(manifest.get("종류")), values) + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + target.mkdir(parents=True) + for file, content in files.items(): + if isinstance(content, bytes): + (target / file).write_bytes(content) + else: + atomic_write_json(target / file, content) + extra = {"값": values, "원값": old} if values else {} + atomic_write_json(target / MANIFEST_NAME, {**manifest, **extra}) # 마지막 — 있으면 다 쓴 폴더 + + +def _read_copy(folder: Path) -> tuple[dict[str, Any], dict[str, Any]] | None: + """다 쓴 사본 → (원값 되돌린 파일들, manifest(값 · 원값 뺌)) — 없으면 None.""" + manifest = read_json(folder / MANIFEST_NAME) + if not NAME.match(folder.name) or not isinstance(manifest, dict): + return None + files: dict[str, Any] = {} + for path in sorted(folder.iterdir()): + if path.is_file() and path.name != MANIFEST_NAME: + files[path.name] = read_json(path) if path.suffix == ".json" else path.read_bytes() + _restore(files, str(manifest.get("종류")), manifest.get("원값")) + return files, {k: v for k, v in manifest.items() if k not in ("값", "원값")} + + def import_copy( project_root: str | Path, kind: str, @@ -72,33 +230,105 @@ def import_copy( owner: str | None, key: str, who: dict[str, Any] | None = None, + haul: dict[str, Any] | None = None, ) -> dict[str, Any]: """서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) · + haul = 운반 값(263-2 · `{given, own, dist}`) · 원본이 없으면 LookupError · 모양이 틀리면 ValueError.""" - if kind not in KINDS: - raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") - name = folder_name(kind, layer, owner, key) - target = library_dir(project_root) / name - if isinstance(read_json(target / MANIFEST_NAME), dict): + haul = haul or {} + lib = haul.get("lib") # 있는 사본에서(굳힌 내용 · 같은 항목만) + if isinstance(lib, str) and lib.split("~")[0] == folder_name(kind, layer, owner, key): + if name := resolve(project_root, {**haul, "lib": lib}, haul.get("dist")): + return {"폴더": name, "만듦": False} + files, origin = _build(kind, layer, owner, key) + values = haul_values(lines_of(files), haul.get("given"), haul.get("own"), haul.get("dist")) + name = folder_name(kind, layer, owner, key, values) + if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict): return {"폴더": name, "만듦": False} - if kind == "combo": - item = {"layer": layer, "owner": owner, "key": key} - item["name"] = item_name(item) - files, origin = build_snapshot(item), origin_of(item) - else: - files = _precombo_files(layer, owner, key) - origin = files.pop("_원본") - if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 - shutil.rmtree(target) - target.mkdir(parents=True) - for file, content in files.items(): - atomic_write_json(target / file, content) - stamp = now_text() - manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who} - atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who} + _write(project_root, name, files, manifest, values) return {"폴더": name, "만듦": True} +def library_inputs( + project_root: str | Path, kind: str, layer: str, owner: str | None, key: str, lib: Any = None +) -> list[dict[str, Any]]: + """담은 로직 입력 · 값(263-2 · 읽기만) — 있는 사본(`lib`)이면 그 덮기 전 값 · + 없으면 서고 원본.""" + got = None + if isinstance(lib, str) and NAME.match(lib): + got = _read_copy(library_dir(project_root) / lib) + files = got[0] if got else _build(kind, layer, owner, key)[0] + out = [] + for lid, line in lines_of(files): + logic = _logic(str(line["로직"])) + out.append( + { + "id": lid, + "로직": line["로직"], + "이름": logic.get("이름") or line["로직"], + "입력": logic.get("입력") or [], + "값": line.get("입력값") or {}, + } + ) + return out + + +def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | None: + """운반 연결 사본 → 지금 거리 · 손 값의 사본 폴더(없으면 그 사본에서 새로 만듦) · + 그 사본이 지워졌으면(prune 뒤 옛 연결) 같은 바탕 사본 → 서고 원본에서 · 못 하면 None.""" + lib = str(ref.get("lib") or "") + if not NAME.match(lib): + return None + base = lib.split("~")[0] + got = _read_copy(library_dir(project_root) / lib) + if got is None: # 같은 바탕 사본 + same = [f for f, _m in copies(project_root) if f.name.split("~")[0] == base] + got = _read_copy(same[0]) if same else None + if got is None: + kind, layer, owner, key = base.split("@") + owner = None if owner == "-" else owner.replace("-", "/") + try: + got = _build(kind, layer, owner, key) + except (LookupError, ValueError): + return None + got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}) + files, manifest = got + values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist) + name = base + (f"~{tag(values)}" if values else "") + target = library_dir(project_root) / name / MANIFEST_NAME + if not isinstance(read_json(target), dict): + _write(project_root, name, files, manifest, values) + return name + + +def resolve_hauls(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]: + """셈 사용자 값 — 운반공 줄 서고 사본 연결(덮개 줄 · 거리이정표 연결)을 지금 거리의 사본으로.""" + dist = user.get("haul_dist") or {} + + def fix(ref: Any, key: Any) -> Any: + if key not in dist or not (isinstance(ref, dict) and ref.get("tab") == "dg"): + return ref + name = resolve(project_root, ref, dist[key]) if ref.get("lib") else None + return {**ref, "lib": name} if name and name != ref["lib"] else ref + + bill = user.get("bill") if isinstance(user.get("bill"), dict) else {} + rows = [ + {**r, "ref": fix(r["ref"], r.get("key"))} if isinstance(r, dict) and "ref" in r else r + for r in bill.get("rows") or [] + ] + hauls = {k: fix(ref, k) for k, ref in (user.get("haul_refs") or {}).items()} + return {**user, "bill": {**bill, "rows": rows}, "haul_refs": hauls} + + +def fix_keys(user: dict[str, Any], libs: dict[str, str]) -> None: + """사본 폴더 → 대가 블록 id — 운반 연결 key 를 바뀐 사본 블록으로(제자리).""" + for row in ref_rows(user): + ref = row.get("ref") + if isinstance(ref, dict) and ref.get("tab") == "dg" and ref.get("lib") in libs: + ref["key"] = libs[ref["lib"]] + + def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: """다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례.""" base = library_dir(project_root) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 7b0e48a41..f52e17869 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -12,7 +12,12 @@ POST /api/projects/{id}/estimation/library {kind, layer, owner, key} 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · - 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) + 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → + [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) · + 운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`) + POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?} + 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) +운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · @@ -134,15 +139,17 @@ def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]: def calc_user(root: Path) -> dict[str, Any]: - """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄).""" - return {**store.read_user(root), **haul_user(haul_routes(root))} + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄) · + 운반 사본 연결 = 지금 거리의 사본(263-2).""" + user = {**store.read_user(root), **haul_user(haul_routes(root))} + return library.resolve_hauls(root, user) async def _demand(root: Path) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + 거리이정표 운반 연결(251-5).""" + user = await asyncio.to_thread(calc_user, root) # 운반 값 사본 먼저(263-2) keys = await asyncio.to_thread(linked_resource_keys, root) - user = await asyncio.to_thread(calc_user, root) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) return [*keys, *(k for k in hand if k not in keys)] @@ -158,11 +165,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) hauls = await asyncio.to_thread(haul_routes, root) - inputs["bill"] = bill_items({**b08, **haul_user(hauls), "haul_routes": hauls}) + user = await asyncio.to_thread(store.read_user, root) + calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)}) + inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls}) inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) - user = await asyncio.to_thread(store.read_user, root) - calc = {**user, **haul_user(hauls)} + library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록 sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) if schedule_draft: schedule_draft.attach(inputs, sheets, user) @@ -191,7 +199,11 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) user = await asyncio.to_thread(store.write_user, root, body.get("user")) - calc = {**user, **haul_user(await asyncio.to_thread(haul_routes, root))} + calc = await asyncio.to_thread(calc_user, root) # 263-2 운반 값 사본 = 지금 거리 + if (rows := calc["bill"].get("rows")) != ((user.get("bill") or {}).get("rows") or []): + # 바뀐 사본 연결 = 저장 값에도(응답 user → 화면 캐시 · 다음 [저장]이 옛 lib 안 씀) + bill = {**user["bill"], "rows": rows} + await asyncio.to_thread(store.write_user, root, {**user, "bill": bill}) await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) @@ -251,6 +263,11 @@ class LibraryBody(BaseModel): layer: str owner: str | None = None key: str + # 263-2 운반공 줄 — 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리 · 있는 사본 + given: dict[str, Any] | None = None + own: list[str] | None = None + dist: dict[str, Any] | None = None + lib: str | None = None @router.post("/{project_id}/estimation/library") @@ -261,7 +278,14 @@ async def import_library( root = await _project_root(project_id) try: made = await asyncio.to_thread( - library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session) + library.import_copy, + root, + body.kind, + got.layer, + got.owner, + body.key, + importer(session), + {"given": body.given, "own": body.own, "dist": body.dist, "lib": body.lib}, ) except LookupError as error: raise HTTPException(status_code=404, detail=str(error)) from error @@ -272,5 +296,22 @@ async def import_library( return {**made, "key": key, "payload": out} +@router.post("/{project_id}/estimation/library-inputs") +async def library_inputs( + project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + got = access.grant(session, body.layer, body.owner) + root = await _project_root(project_id) + try: + lines = await asyncio.to_thread( + library.library_inputs, root, body.kind, got.layer, got.owner, body.key, body.lib + ) + 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 + return {"lines": lines} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 1016fcac6..cca16ec2c 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -122,7 +122,17 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: tab = ref.get("tab") if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]: lib = ref.get("lib") if tab == "dg" else None - return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} + out = {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} + given, own = ref.get("given"), ref.get("own") # 263-2 운반 값 사본 손 값 · 덮은 칸 + if isinstance(lib, str) and isinstance(given, dict): + out["given"] = { + str(lid): {str(k): v for k, v in vals.items() if isinstance(v, (str, int, float))} + for lid, vals in given.items() + if isinstance(vals, dict) + } + if isinstance(lib, str) and isinstance(own, list): + out["own"] = [str(x)[:200] for x in own if isinstance(x, str)][:200] + return out if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts index 8dd19e62c..41956e4e1 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -2,11 +2,13 @@ * B09_Estimation_UI_Haul_Modal.ts * 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · - * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) · + * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 · + * 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) · * 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) + * 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · * 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). + * 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}. * 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. * ========================================================================== */ @@ -27,8 +29,12 @@ import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + fetchLibInputs, importLib, openPriceLink, + parseLib, + type LibLine, + type LibPick, type Picked, type PriceLinkOpts, } from "./B09_Estimation_UI_Tabs_Bill_Link"; @@ -98,11 +104,18 @@ const KIND_TEXT: Record = { export function openHaulModal(o: HaulModalOpts): void { const logicRef = o.ref?.tab === "logic" ? o.ref : null; const old = (logicRef?.cycle as Row) || {}; + const libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null; const st = { /** 고른 것 — 단가산출 아닌 것(조합 · 호표) */ - other: (o.ref && !logicRef - ? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } } - : null) as Picked | null, + other: (libRef + ? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" } + : o.ref && !logicRef + ? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } } + : null) as Picked | null, + /** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */ + lines: [] as LibLine[], + libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record, + libOwn: new Set((libRef && (o.ref?.own as string[])) || []), code: String(logicRef?.code ?? ""), name: String(logicRef?.name ?? ""), given: { ...((logicRef?.given as Row) || {}) } as Row, @@ -135,8 +148,18 @@ export function openHaulModal(o: HaulModalOpts): void { const names = (): string[] => st.inputs.map((i) => i.이름); const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; + const isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } => + p?.kind === "combo" || p?.kind === "precombo"; + /** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */ + const fromDist = (name: string): unknown => + o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined; + const redraw = (): void => { - const fills = st.code !== "" && names().some((n) => n in CYCLE_FILL); + const fills = + (st.code !== "" && names().some((n) => n in CYCLE_FILL)) || + (isLib(st.other) && + !!o.dist && + st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL))); const input = cycleInputs(ref(), o.dist); const head2 = fills ? [ @@ -147,6 +170,14 @@ export function openHaulModal(o: HaulModalOpts): void { : []; window.clearTimeout(timer); const my = ++seq; + if (isLib(st.other)) { + steps.textContent = [ + ...head2, + `${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`, + "단가 = [저장] 뒤 서버 셈", + ].join("\n"); + return; + } if (!st.code) { const what = st.other ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` @@ -180,36 +211,73 @@ export function openHaulModal(o: HaulModalOpts): void { }); const shows: Array<() => void> = []; + /** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) */ + const valueRow = ( + input: ItemInput, + auto: () => unknown, + hand: () => unknown, + set: (value: unknown) => void, + ): HTMLElement => { + const now = auto(); + const field = createValueControl( + input, + ((now === undefined ? hand() : now) as string | number | null) ?? null, + (next, bad) => { + if (bad) return; + const empty = next === null || next === ""; + const num = typeof next === "string" && Number.isFinite(Number(next)); + set(empty ? null : num ? Number(next) : next); + update(); + }, + ); + field.control.setAttribute("data-labor-input", input.이름); + const tag = el("span", { className: "m02-labin__muted" }); + shows.push(() => { + const value = auto(); + tag.textContent = value === undefined ? "" : FROM_DIST; + if (value !== undefined && document.activeElement !== field.control) + field.control.value = String(value ?? ""); + }); + return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); + }; const logicRows = (): HTMLElement[] => st.inputs.map((input) => { - const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름); - const value = fill ? filled()[input.이름] : st.given[input.이름]; - const field = createValueControl( + const name = input.이름; + const fill = name in CYCLE_FILL; + return valueRow( input, - (value as string | number | null) ?? null, - (next, bad) => { - if (bad) return; - if (next === null || next === "") { - delete st.given[input.이름]; - st.own.delete(input.이름); - } else { - st.given[input.이름] = - typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next; - if (input.이름 in CYCLE_FILL) st.own.add(input.이름); - } - update(); + () => (fill && !st.own.has(name) ? filled()[name] : undefined), + () => st.given[name], + (value) => { + if (value === null) delete st.given[name]; + else st.given[name] = value; + if (fill && value !== null) st.own.add(name); + else st.own.delete(name); }, ); - field.control.setAttribute("data-labor-input", input.이름); - const tag = el("span", { className: "m02-labin__muted" }); - shows.push(() => { - const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름); - tag.textContent = auto ? FROM_DIST : ""; - if (auto && document.activeElement !== field.control) - field.control.value = String(filled()[input.이름] ?? ""); - }); - return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); }); + /** 조합 = 담은 로직 이름별 입력(263-2) */ + const libRows = (): HTMLElement[] => + st.lines.flatMap((line) => [ + el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }), + ...line.입력.map((input) => { + const name = input.이름; + const id = `${line.id}|${name}`; + const fill = name in CYCLE_FILL; + return valueRow( + input, + () => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined), + () => st.libGiven[line.id]?.[name] ?? line.값[name], + (value) => { + const mine = (st.libGiven[line.id] ??= {}); + if (value === null) delete mine[name]; + else mine[name] = value; + if (fill && value !== null) st.libOwn.add(id); + else st.libOwn.delete(id); + }, + ); + }), + ]); /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ const update = (): void => { @@ -224,7 +292,11 @@ export function openHaulModal(o: HaulModalOpts): void { ? st.inputs.length ? logicRows() : [muted("입력 없음")] - : [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")]; + : isLib(st.other) + ? st.lines.length + ? libRows() + : [muted("담은 로직 입력 없음")] + : [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")]; left.replaceChildren( el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }), ...lines, @@ -256,11 +328,25 @@ export function openHaulModal(o: HaulModalOpts): void { (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), ); }; + /** 조합 담은 로직 입력 읽고 왼쪽 새로 */ + const useLib = (p: LibPick): void => { + fetchLibInputs(p).then( + (lines) => { + st.lines = lines; + refresh(); + }, + (error: unknown) => showToast(`조합 입력을 못 읽음 — ${String(error)}`, "error"), + ); + }; const usePicked = (p: Picked): void => { if (p.kind === "logic") return useLogic(p.code, p.name); st.other = p; st.code = ""; st.inputs = []; + st.lines = []; + st.libGiven = {}; + st.libOwn.clear(); + if (isLib(p)) useLib(p); refresh(); }; const change = createButton({ @@ -275,8 +361,11 @@ export function openHaulModal(o: HaulModalOpts): void { onClick: () => { const p = st.other; if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error"); - if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p); - else if (p && "ref" in p) o.apply(p.ref, p.from); + if (isLib(p)) { + const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist }; + const { kind, layer, owner, key, lib } = p; + void importLib(o, { kind, layer, owner, key, lib, haul }); + } else if (p && "ref" in p) o.apply(p.ref, p.from); else { const next: Row = { tab: "logic", @@ -305,7 +394,10 @@ export function openHaulModal(o: HaulModalOpts): void { }), ); if (st.code) useLogic(st.code, st.name); - else refresh(); + else { + if (libRef) useLib(libRef); + refresh(); + } }, }); } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 20a4bf31a..050b30ed8 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -16,6 +16,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { loadRows } from "@ui/ui_template_item_picker"; import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; +import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; @@ -61,13 +62,55 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row ); } -/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) */ +/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) · + * lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */ export type LibPick = { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string; + lib?: string; + haul?: { given: Row; own: string[]; dist: Dist }; }; +/** 담은 로직 줄 입력 · 값(263-2 · `POST …/estimation/library-inputs`) */ +export interface LibLine { + id: string; + 로직: string; + 이름: string; + 입력: ItemInput[]; + 값: Row; +} + +/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */ +export function parseLib(lib: string): LibPick | null { + const [kind, layer, owner, key] = lib.split("~")[0].split("@"); + if ((kind !== "combo" && kind !== "precombo") || !key) return null; + return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib }; +} + +async function postLib(path: string, body: Row): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) throw new Error("프로젝트 없음"); + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +/** 서고 항목 담은 로직 입력 · 값(있는 사본이면 그 덮기 전 값) */ +export async function fetchLibInputs(lib: LibPick): Promise { + const { kind, layer, owner, key } = lib; + const got = await postLib("library-inputs", { kind, layer, owner, key, lib: lib.lib ?? null }); + return (got.lines as LibLine[]) || []; +} export type Picked = | { kind: "logic"; code: string; name: string } | (LibPick & { name: string; unit: string }) @@ -80,27 +123,17 @@ export async function importLib( lib: LibPick, ): Promise { const apply = o.apply; - const pick = { kind: lib.kind, layer: lib.layer, owner: lib.owner, key: lib.key }; - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) return; + const { kind, layer, owner, key } = lib; + const pick = { kind, layer, owner, key, ...(lib.lib && { lib: lib.lib }), ...lib.haul }; showToast("가져오는 중…", "info"); try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pick), - }); - const got = (await response.json().catch(() => null)) as Row | null; - if (!response.ok || !got) { - const detail = got?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } + const got = await postLib("library", pick); if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); const { user: _user, ...rest } = got.payload as Row; o.onPayload?.(rest); const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); - apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {}; + apply({ tab: "dg", key: got.key, lib: got.폴더, ...hand }, from); showToast( got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함", "success", diff --git a/resources/tester/test_263_1_haul_modal.py b/resources/tester/test_263_1_haul_modal.py index 8df0d54f4..dfdc2ad01 100644 --- a/resources/tester/test_263_1_haul_modal.py +++ b/resources/tester/test_263_1_haul_modal.py @@ -37,7 +37,7 @@ def test_왼쪽_칸_고른_로직_입력만(): def test_변경_한_목록(): modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") - assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal + assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, {" in modal link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") diff --git a/resources/tester/test_263_2_haul_lib_copy.py b/resources/tester/test_263_2_haul_lib_copy.py new file mode 100644 index 000000000..6f74cbdf0 --- /dev/null +++ b/resources/tester/test_263_2_haul_lib_copy.py @@ -0,0 +1,140 @@ +"""263-2 운반 값 사본 — 사전조합 · 구조물 조합 담은 로직 거리 칸 = 거리이정표 거리 · +같은 값 = 사본 하나 · 다른 거리 = 사본 둘 · 거리 바뀐 뒤 셈 = 새 사본 · 옛 사본 prune · +옛 이름(해시 없음) 폴더 읽힘 · 손 값(own) 이김.""" + +import shutil +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402 +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Store import _clean_ref # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 + +KEY = "SC-999263" +DOC = { + "종류": "사전조합", + "키": KEY, + "이름": "시험 덤프운반 조합", + "단위": "㎥", + "담은로직": [ + {"로직": "GF000284", "입력값": {"재료": "토사", "지역": "전국평균", + "보정작업": "해당 없음"}, "수량": {"손": 1}}, + {"로직": "GF000134", "입력값": {"qt": 15, "f": 1, "E": 0.9, "지역": "전국평균"}, + "수량": {"손": 1}}, + ], +} # fmt: skip +NEAR = {"L1": 10, "L2": 2} +FAR = {"L1": 30, "L2": 2} +LID = f"{refs.ref_id({'층': 'system', '주인': None, '키': KEY})}#0" +LID2 = LID[:-1] + "1" + + +@pytest.fixture(autouse=True) +def fake_precombo(monkeypatch): + real = refs.read_ref + monkeypatch.setattr(refs, "read_ref", lambda ref: DOC if ref.get("키") == KEY else real(ref)) + + +def _import(root, dist, given=None, own=None): + haul = {"given": given, "own": own, "dist": dist} + return library.import_copy(root, "precombo", "system", None, KEY, None, haul)["폴더"] + + +def _given(root, folder): + files = library._read_copy(library.library_dir(root) / folder) + manifest = library.read_json(library.library_dir(root) / folder / library.MANIFEST_NAME) + return manifest.get("값") or {}, files + + +def test_거리_칸_채움_같은_값_사본_하나_다른_거리_둘(tmp_path): + a = _import(tmp_path, NEAR) + b = _import(tmp_path, NEAR) + c = _import(tmp_path, FAR) + assert a == b and a != c and "~" in a + values, _ = _given(tmp_path, a) + assert values[LID] == {"운반거리": 12} # L1 + L2 + assert values[LID2] == {"Cm": cycle_time(NEAR)["cm"]} + # 사본 안 로직 줄 입력값 = 덮은 값 · 원값 = 덮기 전(없던 칸) + labor = library.read_json(library.library_dir(tmp_path) / c / library.LABOR_FILE) + doc = labor["참조사본"][LID.split("#")[0]] + assert ( + doc["담은로직"][0]["입력값"]["운반거리"] == 32 + and doc["담은로직"][0]["입력값"]["재료"] == "토사" + ) + # 대가 블록 둘(값 다름 = 블록 다름) + libs = daega_inputs(tmp_path)["libs"] + assert set(libs) == {a, c} and libs[a] != libs[c] + + +def test_손_값_이김(tmp_path): + own = [f"{LID}|운반거리"] + folder = _import(tmp_path, NEAR, {LID: {"운반거리": 5, "재료": "암절취"}}, own) + values, _ = _given(tmp_path, folder) + assert values[LID] == {"운반거리": 5, "재료": "암절취"} + lines = library.library_inputs(tmp_path, "precombo", "system", None, KEY, folder) + first = next(x for x in lines if x["id"] == LID) + assert first["로직"] == "GF000284" and first["값"]["재료"] == "토사" # 덮기 전 값 + assert [i["이름"] for i in first["입력"]] == ["재료", "운반거리", "지역", "보정작업"] + + +def test_거리_바뀐_뒤_셈_새_사본_옛_사본_prune(tmp_path): + old = _import(tmp_path, NEAR) + ref = {"tab": "dg", "key": "DG:x", "lib": old, "given": {}, "own": []} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": FAR}} + calc = library.resolve_hauls(tmp_path, user) + new = calc["haul_refs"]["haul:r-u1"]["lib"] + assert new != old and new == _import(tmp_path, FAR) + libs = daega_inputs(tmp_path)["libs"] + library.fix_keys(calc, libs) + assert calc["haul_refs"]["haul:r-u1"]["key"] == libs[new] + assert library.prune(tmp_path, calc) == [old] + # 같은 거리 다시 = 그대로 + assert library.resolve_hauls(tmp_path, user)["haul_refs"]["haul:r-u1"]["lib"] == new + # 설계내역서 덮개 줄도 같이 + bill = {"bill": {"rows": [{"key": "haul:r-u1", "ref": ref}]}, "haul_dist": {"haul:r-u1": NEAR}} + assert library.resolve_hauls(tmp_path, bill)["bill"]["rows"][0]["ref"]["lib"] == old + + +def test_옛_이름_폴더_읽힘(tmp_path): + hashed = _import(tmp_path, NEAR) + plain = library.import_copy(tmp_path, "precombo", "system", None, KEY)["폴더"] + assert "~" not in plain and library.NAME.match(plain) + ref = {"tab": "dg", "key": "DG:x", "lib": plain} + assert library.resolve(tmp_path, ref, NEAR) == hashed # 옛 사본에서 거리 값 사본 + shutil.rmtree(library.library_dir(tmp_path) / hashed) + assert library.resolve(tmp_path, ref, NEAR) == hashed # 없으면 옛 사본에서 다시 만듦 + assert library.resolve(tmp_path, ref, None) == plain # 거리 없음 = 그대로 + assert {f.name for f, _m in library.copies(tmp_path)} == {plain, hashed} + + +def test_연결_손_값_저장_꼴(): + ref = {"tab": "dg", "key": "DG:x", "lib": "precombo@system@-@SC-1~abcdef12", + "given": {LID: {"운반거리": 5, "x": [1]}}, "own": [f"{LID}|운반거리", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["given"] == {LID: {"운반거리": 5}} and got["own"] == [f"{LID}|운반거리"] + assert "given" not in _clean_ref({"tab": "dg", "key": "DG:x"}) + + +def test_있는_사본에서_다시_적용(tmp_path, monkeypatch): + near = _import(tmp_path, NEAR) + monkeypatch.setattr(refs, "read_ref", lambda ref: None) # 원본 지워짐 = 굳힌 사본에서 + haul = {"given": {}, "own": [], "dist": FAR, "lib": near} + got = library.import_copy(tmp_path, "precombo", "system", None, KEY, None, haul) + values, _ = _given(tmp_path, got["폴더"]) + assert got["폴더"] != near and values[LID] == {"운반거리": 32} + + +def test_모달_조합_입력(): + modal = (ROOT / "B09_Estimation/B09_Estimation_UI_Haul_Modal.ts").read_text("utf-8") + for want in ("fetchLibInputs(p)", "libRows()", "st.libOwn.has(id)", "parseLib(", + "importLib(o, { kind, layer, owner, key, lib, haul })"): # fmt: skip + assert want in modal, want + link = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + assert '"library-inputs"' in link and "...lib.haul" in link