diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py new file mode 100644 index 000000000..b1c1a16c6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -0,0 +1,134 @@ +"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2). + +자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}` +(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표): + combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 · + 도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값) + precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} · + `단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌) +사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) · +대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · +단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · +[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`). +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Store import folder as store_folder +from common_util.common_util_json import atomic_write_json +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_Source import ( + LABOR_FILE, + PRICE_FILE, + item_name, + labor_doc_rows, + price_bundle, + read_json, +) + +LIBRARY = "library" +KINDS = ("combo", "precombo") +#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) +NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$") + + +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.""" + name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}" + if not NAME.match(name): + raise ValueError(f"서고 항목 모양이 틀림 「{name}」") + return name + + +def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: + ref = {"층": layer, "주인": owner, "키": key} + doc = refs.read_ref(ref) + if doc is None or doc.get("종류") != refs.TITLE["precombo"]: + raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음") + line = {"참조": ref, "수량": {"손": 1}} + labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}) + return { + LABOR_FILE: labor, + PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)), + "_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""}, + } + + +def import_copy( + project_root: str | Path, + kind: str, + layer: str, + owner: str | None, + key: str, + who: dict[str, Any] | None = None, +) -> dict[str, Any]: + """서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) · + 원본이 없으면 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): + 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) # 마지막 — 있으면 다 쓴 폴더 + return {"폴더": name, "만듦": True} + + +def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: + """다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례.""" + base = library_dir(project_root) + out = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + manifest = read_json(child / MANIFEST_NAME) + if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict): + out.append((child, manifest)) + return out + + +def used_folders(user: dict[str, Any]) -> set[str]: + """설계내역서 덮개 줄이 잇는 서고 사본 폴더.""" + rows = ((user or {}).get("bill") or {}).get("rows") or [] + return { + str(ref["lib"]) + for row in rows + if isinstance(row, dict) + and isinstance(ref := row.get("ref"), dict) + and ref.get("tab") == "dg" + and ref.get("lib") + } + + +def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]: + """[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름.""" + keep = used_folders(user) + base = library_dir(project_root) + gone = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + if child.is_dir() and NAME.match(child.name) and child.name not in keep: + shutil.rmtree(child) + gone.append(child.name) + return gone diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index cb49e0f09..578f7dc74 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -26,6 +26,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -282,6 +283,19 @@ class Builder: } ) + def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: + """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" + if manifest.get("종류") != "precombo": + return self.combo(folder) + labor = read_json(folder / LABOR_FILE) + ref = (manifest.get("원본") or {}).get("참조") + copies = (labor or {}).get("참조사본") or {} + doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None + if not isinstance(doc, dict): + return None + whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + return self.precombo(whole, ref, doc, copies, 1) + def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" @@ -327,9 +341,11 @@ def _plain(value: Any) -> Any: def daega_inputs(project_root: str | Path) -> dict[str, Any]: - """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것. + """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 · + 설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}. - 차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례. + 차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — + 호표 번호 = 이 차례. """ base = structures_dir(project_root) build = Builder() @@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]: bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음 if bid: owners[owner] = bid + libs: dict[str, str] = {} + for folder, manifest in library.copies(project_root): + try: + bid = build.library(folder, manifest) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + bid = None + if bid: + libs[folder.name] = bid order = {"조합": 0, "사전조합": 1, "품셈": 2} blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]]) - return _plain({"blocks": blocks, "owners": owners}) + out = {"blocks": blocks, "owners": owners} + return _plain({**out, "libs": libs} if libs else out) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ff2427dd3..868e8e7e3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]: def linked_resource_keys(project_root: str | Path) -> list[str]: - """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음.""" + """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · + 겹침 없음.""" + from B09_Estimation.B09_Estimation_Bill_Library import copies + base = structures_dir(project_root) + folders = [ + base / linked["폴더"] + for _owner, linked in sorted(linked_snapshots(project_root).items()) + if "일위대가" in linked.get("부분", []) + ] out: list[str] = [] - for _owner, linked in sorted(linked_snapshots(project_root).items()): - if "일위대가" not in linked.get("부분", []): - continue - for key in used_keys(base / linked["폴더"]): + for folder in [*folders, *(f for f, _ in copies(project_root))]: + for key in used_keys(folder): if key not in out: out.append(key) return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 0c36b0627..62ccb65e8 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,10 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/library {kind, layer, owner, key} + 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 + (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · + 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · @@ -20,11 +24,13 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Depends, HTTPException +from pydantic import BaseModel from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store @@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete.M02_Item_Snapshot import importer router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -141,7 +150,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]: @router.put("/{project_id}/estimation") async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) - await asyncio.to_thread(store.write_user, root, body.get("user")) + user = await asyncio.to_thread(store.write_user, root, body.get("user")) + await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) @@ -158,5 +168,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +class LibraryBody(BaseModel): + kind: str + layer: str + owner: str | None = None + key: str + + +@router.post("/{project_id}/estimation/library") +async def import_library( + project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409 + 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) + ) + 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 + out = await payload(project_id, root) + key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"]) + return {**made, "key": key, "payload": out} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index da7557fbd..0f1c5862a 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -95,12 +95,14 @@ def _text(value: Any) -> str: def _clean_ref(ref: Any) -> dict[str, Any] | None: - """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름).""" + """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) · + dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움).""" if not isinstance(ref, dict): return None tab = ref.get("tab") if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]: - return {"tab": tab, "key": ref["key"]} + lib = ref.get("lib") if tab == "dg" else None + return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} 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_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 4bd17684e..0e384d428 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -1,14 +1,19 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Link.ts * B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · - * M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기. + * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 + * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · + * 연결 풀기. * * 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름) - * 로직 고르기 · 입력값 = M02 창 재사용 + * 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 · + * 층 읽기 권한 = 서버(`M02_Layer_Access`) * ========================================================================== */ +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; +import { openItemPicker } from "@ui/ui_template_item_picker"; import { openModal } from "@ui/ui_template_modal"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; @@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void ); } +/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 + * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ +async function importLib( + ctx: TabContext, + apply: ApplyRef, + pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, +): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return; + 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}`); + } + if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); + const { user: _user, ...rest } = got.payload as Row; + Object.assign(ctx.payload, rest); + const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); + apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + showToast( + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + "success", + ); + } catch (error) { + showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + } +} + +/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */ +function pickCombo(ctx: TabContext, apply: ApplyRef): void { + void openItemPicker({ current: null, canCopy: false }).then((got) => { + if (got.action !== "apply") return; + const { layer, owner, key } = got.item; + void importLib(ctx, apply, { kind: "combo", layer, owner, key }); + }); +} + +/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */ +function pickPrecombo(ctx: TabContext, apply: ApplyRef): void { + openLogicPicker(() => undefined, { + layer: "personal", + owner: null, + libOnly: true, + title: "일위대가 사전조합 고르기", + pick: (ref) => + void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + }); +} + /** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { const more: Leaf[] = []; @@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], + ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], + ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], [ "단가산출 로직(M01 · 새 산근)", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 717791ba8..597cb086c 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -132,8 +132,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]: 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: @@ -153,6 +151,14 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor + files[PRICE_FILE] = price_bundle(labor, root) + return files + + +def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: + """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · + 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" + from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Labor_Ref as refs logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] @@ -164,9 +170,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 wanted = logic_keys + [code for code in materials if code] - files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 - files[PRICE_FILE][own.BUNDLE] = mine - return files + bundle[own.BUNDLE] = mine + return bundle diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7dafc26ec..a7cc104cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -34,6 +34,9 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ + libOnly?: boolean; + title?: string; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -213,7 +216,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra el("div", { className: "m01-logic__section-head", children: [ - el("h3", { text: tc("Pick_Title") }), + el("h3", { text: lib?.title ?? tc("Pick_Title") }), createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), @@ -226,14 +229,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); finder.search.focus(); - void Promise.all([fetchLogicSubs(), fetchLogics()]) - .then(([s, l]) => { - subs.push(...s); - all = l; - drawSubs(); - find(); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); + if (!lib?.libOnly) + void Promise.all([fetchLogicSubs(), fetchLogics()]) + .then(([s, l]) => { + subs.push(...s); + all = l; + drawSubs(); + find(); + }) + .catch((error) => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), + ); } diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 8b938d94d..cccc9ecf4 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -317,7 +317,13 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): ] # fmt: skip 201-1 레벨 = 공용 표 level assert out["lv"] == ["name", 1, 3, 4, 3] assert out["edit"] == [True, False] # 묶음 줄 = 이름만 - picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] + picks = [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] # 204-2 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py new file mode 100644 index 000000000..c89d23564 --- /dev/null +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -0,0 +1,244 @@ +"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인) +구조물조합 · 사전조합. + +- 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) + + 줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표) +- 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦 +- [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴 +- 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409) +- 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 +from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ +NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM +NET_ITEM = "MT006697" +MAT = "MT000008" +COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄) +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + lines = [ + {"로직": STONE, "수량": {"손": 2}}, + {"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}}, + ] + with store.at(system): + key = refs.create( + "precombo", + {"이름": "서고 조합", "단위": "㎡", "담은로직": lines, + "자재": [{"코드": MAT, "수량": {"손": 3}}]}, + )["이름"] # fmt: skip + company = layers.company_dir(7) + company.mkdir(parents=True) + with store.at(company): + other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + project = tmp_path / "storage" / "7" / "1" / "proj" + project.mkdir(parents=True) + return {"system": system, "key": key, "company": other, "root": project} + + +def _block(root, folder): + got = daega_inputs(root) + bid = got["libs"][folder] + return got, next(b for b in got["blocks"] if b["id"] == bid) + + +def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world): + root = world["root"] + made = library.import_copy(root, "precombo", "system", None, world["key"]) + folder = made["폴더"] + assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True} + copy = library.library_dir(root) / folder + assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"} + got, block = _block(root, folder) + assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") + kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] + assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)] + assert got["owners"] == {} # 구조물 사본 없어도 호표 + # 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음) + bundle = json.loads((copy / "단가.json").read_text("utf-8")) + text = json.dumps(bundle, ensure_ascii=False) + assert all(code in text for code in (STONE, NET, MAT, NET_ITEM)) + keys = linked_resource_keys(root) + assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦 + # 다시 고름 = 새로 안 들여옴 · 같은 호표 + again = library.import_copy(root, "precombo", "system", None, world["key"]) + assert again == {"폴더": folder, "만듦": False} + assert _block(root, folder)[1]["id"] == block["id"] + # 원본을 지워도 굳힌 사본 그대로 + (world["system"] / "precombo" / f"{world['key']}.json").unlink() + assert _block(root, folder)[1]["id"] == block["id"] + + +def test_구조물조합_고름_사본_조합_호표(world): + root = world["root"] + made = library.import_copy(root, "combo", "system", None, COMBO) + copy = library.library_dir(root) / made["폴더"] + names = {p.name for p in copy.iterdir()} + assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names + manifest = json.loads((copy / "manifest.json").read_text("utf-8")) + assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO + assert manifest["원본"]["경로"] # 가져온 때 경로 + _, block = _block(root, made["폴더"]) + assert block["kind"] == "조합" and block["lines"] + assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO} + + +def test_없는_것_모양_틀림(world): + root = world["root"] + with pytest.raises(LookupError): + library.import_copy(root, "precombo", "system", None, "SC-999999") + with pytest.raises(LookupError): + library.import_copy(root, "combo", "system", None, "SS999999") + with pytest.raises(ValueError): + library.import_copy(root, "precombo", "system", None, "../x") + with pytest.raises(ValueError): + library.import_copy(root, "other", "system", None, world["key"]) + assert library.copies(root) == [] + + +def test_저장_때_안_잇는_사본_지움_lib_지킴(world): + root = world["root"] + a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] + ref = {"tab": "dg", "key": "DG:x", "lib": a} + user = store_b09.clean_user( + {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} + ) + assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴 + assert library.prune(root, user) == [b] + assert [f.name for f, _ in library.copies(root)] == [a] + assert library.prune(root, {}) == [a] and library.copies(root) == [] + + +def _call(monkeypatch, root, session, **body): + async def fake_root(_): + return root + + async def fake_payload(_, at): + return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}} + + monkeypatch.setattr(router, "_project_root", fake_root) + monkeypatch.setattr(router, "payload", fake_payload) + req = router.LibraryBody(**body) + return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session)) + + +def test_층_권한_길(world, monkeypatch): + root = world["root"] + mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"} + got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"]) + assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}" + assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]] + assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[ + "가져온이" + ] == {"id": 1, "이름": "갑"} + other = {"user_id": 2, "company_id": 8, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, other, kind="precombo", layer="company", owner="7", + key=world["company"]) # fmt: skip + assert error.value.status_code == 403 + lonely = {"user_id": 3, "company_id": None, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"]) + assert error.value.status_code == 409 + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999") + assert error.value.status_code == 404 + got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"]) + assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음 + + +# ── 화면 ────────────────────────────────────────────────────────────── +LINK_JS = """ +const toasts = []; +const sent = []; +globalThis.showToast = (m, k) => toasts.push([k, m]); +globalThis.API_BASE_URL = "/api"; +globalThis.CURRENT_PROJECT_ID_KEY = "pid"; +globalThis.localStorage = { getItem: () => "P1" }; +globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]); + lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); }; +globalThis.openItemPicker = async () => ({ action: "apply", + item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } }); +const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", + payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, + sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; +globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]); + return { ok: true, status: 200, json: async () => answer }; }; +const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} }; +const applied = []; +const apply = (ref, from) => applied.push([ref, from && from.name]); +const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); +const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; +picks.find((x) => x[0].startsWith("서고 일위대가"))[1](); +await new Promise((r) => setTimeout(r, 0)); +picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +await new Promise((r) => setTimeout(r, 0)); +await new Promise((r) => setTimeout(r, 0)); +Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); +console.log(JSON.stringify(out)); +""" + + +def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): + src = IMPORTS.sub( + "", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + out = json.loads(result.stdout) + assert out["picks"] == [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] + assert out["more"] == ["연결 풀기"] + pre, (url, body) = out["sent"][0], out["sent"][1] + assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 + assert url == "/api/projects/P1/estimation/library" + assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} + assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, + "key": "SS000011"} # fmt: skip + ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} + assert out["applied"][0] == [ref, "회사 조합"] + # 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 + assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} + assert out["toasts"][-1][0] == "success"