"""B09 경쟁사 공사 통째 불러오기(PLAN 707-7) — 엑셀 일위대가 · 단가산출 → 이 공사 서고 사본 (층 쓰기 0). 705-3 [모두 고정으로 들여오기] 그대로(`M02_Hopyo_Build.Builder` · 하위 호표 먼저 · 고정 줄 · 빠짐 0) — 층 대신 임시 폴더(`store.at` · 층 = 「project」)에서 만들고 호표마다 서고 사본으로 굳힘: 폴더 `precombo@project@-@{표지}-{번}` · 원본 참조 `{층 project, 주인 표지, 키 SC-{번}}` · 참조사본 = 그 호표 + 닿는 하위 호표(주인 = 표지 → 다른 파일과 안 섞임) · 단가 묶음 = 서고 사본과 한 길. 읽기 = `M02_Hopyo_Excel.read_book`(엑셀만 — .estx · .STC 구성 줄은 못 읽음) · 원본이 없으니 [업데이트] 대상 아님. """ from __future__ import annotations import json import tempfile from pathlib import Path from typing import Any from B09_Estimation import B09_Estimation_Bill_Library as library from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as mstore from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, now_text from M02_MasterTemplete.M02_Item_Source import ( LABOR_FILE, PRICE_FILE, logic_rows, price_bundle, read_json, ) LAYER = "project" SUM_KEYS = ("품셈", "고정", "호표참조", "빠짐", "이음", "이음후보") def read(name: str, data: bytes) -> dict[str, Any]: """엑셀 일위대가 시트 → 읽은 결과 — 시트 없음 · DRM = `hopyo_excel.ExcelError`.""" return hopyo_excel.read_book(name, data) _PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"} def agree(bill: dict[str, Any], book: dict[str, Any]) -> tuple[set[int], list[dict[str, Any]]]: """내역 줄 대가 번 가운데 내역 단가 = 일위대가표 호표 금액(비목마다 1 원 안)인 번만 이음 · 다른 줄 = 내역 단가 그대로(경쟁사 금액 재현) → (이을 번, 다른 줄 [{행, 이름, 번, 내역, 일위대가}]).""" units = {int(h["번"]): h.get("금액") or {} for h in book.get("호표") or []} good: set[int] = set() bad: list[dict[str, Any]] = [] for line in bill["줄"]: hopyo = line.get("호표") or {} no = int(hopyo["번"]) if hopyo.get("종류") == "대가" and "번" in hopyo else None if no is None or no not in units: continue price = line.get("단가") or {} if all(abs((price.get(p) or 0) - (units[no].get(k) or 0)) < 1 for p, k in _PARTS.items()): good.add(no) continue each = sum(price.get(p) or 0 for p in _PARTS) sheet = sum(units[no].get(k) or 0 for k in _PARTS.values()) bad.append( {"행": line["행"], "이름": line["이름"], "번": no, "내역": each, "일위대가": sheet} ) return good - {x["번"] for x in bad}, bad def _key(no: int) -> str: return f"SC-{no:06d}" def _owned(doc: dict[str, Any], tag: str) -> dict[str, Any]: """하위 호표 참조 주인 = 표지(임시 층 `project` 주인 없음 → 파일마다 가름).""" lines = [ {**one, "참조": {**one["참조"], "주인": tag}} if isinstance(one, dict) and (one.get("참조") or {}).get("층") == LAYER else one for one in doc.get("담은로직") or [] ] return {**doc, "담은로직": lines} def build(parsed: dict[str, Any], name: str, tag: str, who: Any) -> dict[str, Any]: """호표 모두 → `{문서 {번: 사전조합}, 실패, 수 {호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보}}`.""" units = {int(h["번"]): h for h in parsed.get("호표") or []} docs: dict[int, dict[str, Any]] = {} sums = dict.fromkeys(SUM_KEYS, 0) sums |= {"호표": len(units), "줄": sum(len(h.get("줄") or []) for h in units.values())} failed = [] with tempfile.TemporaryDirectory() as scratch, mstore.at(scratch): made_dir = Path(scratch) / "precombo" made_dir.mkdir() key = hopyo_store.save(scratch, name, parsed, who)["키"] for no in hb._order(units): try: made = hb.Builder(Path(scratch), Path(scratch), key, no, who).build({}, [], False) except mstore.StoreError as e: failed.append({"번": no, "까닭": str(e.detail)}) continue doc = {**made["문서"], "종류": refs.TITLE["precombo"], "키": _key(no)} # 다음 호표의 `confirmed` 가 하위로 찾음(본뜬 확인 · 같은 원본 층) (made_dir / f"{_key(no)}.json").write_text( json.dumps(doc, ensure_ascii=False, default=float), "utf-8" ) docs[no] = _owned(json.loads(json.dumps(doc, default=float)), tag) for k in SUM_KEYS: sums[k] += made["줄수"]["호표" if k == "호표참조" else k] return {"문서": docs, "수": sums, "실패": failed} def _ref(tag: str, no: int) -> dict[str, Any]: return {"층": LAYER, "주인": tag, "키": _key(no)} def _copies(docs: dict[int, dict[str, Any]], tag: str, no: int) -> dict[str, dict[str, Any]]: """그 호표 + 닿는 하위 호표 `{ref_id: 문서}`(굳힌 알약까지).""" out: dict[str, dict[str, Any]] = {} todo = [no] while todo: at = todo.pop() rid = refs.ref_id(_ref(tag, at)) if rid in out or at not in docs: continue out[rid] = refs.with_pills(docs[at]) todo += [ int(one["참조"]["키"][3:]) for one in docs[at].get("담은로직") or [] if (one.get("참조") or {}).get("층") == LAYER ] return out def folder_of(tag: str, no: int) -> str: return library.folder_name("precombo", LAYER, None, f"{tag}-{no}") def copy(root: str | Path, built: dict[str, Any], tag: str, no: int, who: Any) -> str | None: """호표 하나 → 서고 사본 폴더 이름(있으면 그대로) · 못 만든 호표 = None.""" docs = built["문서"] if no not in docs: return None name = folder_of(tag, no) if isinstance(read_json(library.library_dir(root) / name / MANIFEST_NAME), dict): return name ref, doc = _ref(tag, no), docs[no] copies = _copies(docs, tag, no) line = {"참조": ref, "수량": {"손": 1}} top = {"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]} pill = refs.pill(ref, lambda r: copies.get(refs.ref_id(r))) labor = {**logic_rows(top), "알약": [pill], "참조사본": copies} files = {LABOR_FILE: labor, PRICE_FILE: price_bundle(labor, Path(mstore.FOLDER))} origin = {"참조": ref, "name": doc.get("이름") or ref["키"], "단위": doc.get("단위") or ""} manifest = {"종류": "precombo", "원본": origin, "가져온날": now_text(), "가져온이": who} library._write(root, name, files, manifest, {}) return name