"""B09 일위대가(대가) 블록 입력(PLAN 145-2) — 서버 단독 · 읽기만 · 값(단가)은 안 씀. 연결된 사본(`M02_Item_Snapshot.linked_snapshots`)마다 `일위대가.json` 을 사본 셈 한 벌 (`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) · 자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) (155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음) 고정 고정 줄(705-1) = `fixed` 줄 하나 · `own` = 줄 단가 · `link` = 이음 키(셈 짝이 단가 책 값) 같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · 덮개 열쇠(702-2 · 내용 해시 밖 · 수량 · 단가가 바뀌어도 그대로) = 블록 `cover` + 줄 `cid` — cover = 조합 `S:사본 폴더` · 서고 `L:폴더` · 사전조합 `C:참조` · 품셈 `P:로직 + 입력 해시` cid = 줄 정체(비목 : 노임 · 자재 · 중기 코드 · 산근 · 부른 대가 로직 · 참조) + `#n번째` 줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 1 단위 몫 · 산근 단가는 이미 1 단위). 단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄. """ from __future__ import annotations import hashlib import json import re 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 B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete.M02_Item_Snapshot import ( MANIFEST_NAME, PICKS_FILE, VALUES_FILE, linked_snapshots, structures_dir, ) from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json mf = item_prices.mf #: 엔진 비목 → 블록 비목 PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} #: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄) BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*") BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*") BASE_LABOR = re.compile(r"^\s*\(.*\*\s*LB\d+") AREA_FILE = "면적.json" def _f(value: Any) -> float | None: if isinstance(value, bool) or value is None: return None try: return float(value) except (TypeError, ValueError): return None def _digest(value: Any) -> str: text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10] def sangeun_ref(code: str, given: Any, rate: Any, picks: Any) -> str: """산근 호표 키 — 145-1 `B09_Estimation_Sangeun.sangeun_key`.""" return sg.sangeun_key(code, given or {}, rate, picks or {}) class Builder: """한 프로젝트 블록 모음 — 같은 id 는 한 번만(처음 본 차례).""" def __init__(self) -> None: self.blocks: dict[str, dict[str, Any]] = {} self.gaps: list[dict[str, str]] = [] def put(self, block: dict[str, Any], cover: str | None = None) -> str: bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}" if bid not in self.blocks: mark = {"품셈": "P", "사전조합": "C"}.get(block["kind"], "S") if cover is None: origin = block["origin"] cover = f"C:{origin['참조']}" if mark == "C" else f"{mark}:{_digest(origin)}" lines = [{**ln, "cid": cid} for ln, cid in zip(block["lines"], self._cids(block))] self.blocks[bid] = {"id": bid, "cover": cover, **block, "lines": lines} return bid def _cids(self, block: dict[str, Any]) -> list[str]: """줄 정체(702-2) — 같은 정체 n번째 = `#n` · 재적용으로 줄이 늘거나 바뀌어도 맞는 줄에.""" seen: dict[str, int] = {} out = [] for line in block["lines"]: kind, key = line["kind"], str(line.get("key") or "") if kind == "daega" and key in self.blocks: origin = self.blocks[key]["origin"] key = str(origin.get("로직") or origin.get("참조") or key) elif kind == "sangeun": key = key.rsplit(":", 1)[0] # SG:로직:입력 해시 → 로직만 elif kind == "pct": key = str(line.get("base") or "") head = f"{kind}:{key or line.get('name') or ''}" seen[head] = seen.get(head, -1) + 1 out.append(f"{head}#{seen[head]}") return out # ── 품셈(로직 하나) ───────────────────────────────────────────────── def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: got = self._poomsem(whole, one, title) if got is None: return None got.pop("_engine"), got.pop("_items") return self.put(got) def _poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> dict | None: """품셈 블록 꼴(넣기 전) — 엔진 줄 · 줄마다 `_n`(엔진 차례 · 단가산출 블록이 씀).""" code = str(one.get("로직") or "") given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름") try: logic = whole.logic(code) except mf.FormulaError: return None got = labor_doc.run_line(whole, code, given, rate, picks) if got.get("기준") is None: return None # 돈 아닌 로직 · 못 돈 로직 — 조합 줄이 까닭을 냄 items = logic.get("호표") or [] lines = [] engine = got.get("줄") or [] bases = _engine_bases(engine[: len(items)], items) calls = sg.line_calls(whole, code, given, picks) for n, x in enumerate(engine): item = items[n] if n < len(items) else None lines.append(self._engine_line(item, x, logic, n - len(items), bases, calls.get(n))) eq = {str(r.get("키")): r for r in (whole.numbers.get("EQ") or {}).values()} for line in lines: if line["kind"] == "machine": line["spec"] = (eq.get(line["key"]) or {}).get("규격") or "" unit = str(logic.get("결과단위") or "").replace("원/", "") spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items())) shown = " · ".join( # 157-4 기계 · 직종 원문번호 = 「이름 규격」(번호 = 화면 풍선 spec_raw) f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}" for k, v in sorted(given.items()) ) return { "kind": "품셈", "name": title or logic.get("이름") or code, "spec": shown, "spec_raw": spec, "unit": unit, "cite": logic.get("출처") or "", "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, "lines": lines, "_engine": engine, "_items": items, } # ── 단가산출(SK · 719-2) ──────────────────────────────────────────── def sangeun(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str | None: """굳힌 SK(사본 `단가.json` 로직 줄 · 719-1) = 품셈 블록 꼴 대가 — 셈 = 굳힌 줄 그대로 · 단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) · 고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) · cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) · 줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`).""" from M02_MasterTemplete import M02_Item_Sangeun as item_sk from M02_MasterTemplete import M02_Sangeun_Calc as calc from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete.M02_Labor_Expand import Expand rid = refs.ref_id(ref) key = str((doc.get("바탕") or {}).get("로직") or "") if item_sk.ROW not in doc or not key: return None # 못 푼 SK(없는 바탕 · 순환) — 부르는 줄이 까닭 def read(r: dict) -> dict | None: return copies.get(refs.ref_id(r)) try: got = calc.resolve(doc, whole, read, (rid,)) row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]} if calc._bundles(row, whole, Expand(whole, read), (rid,)): return None except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]} block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]}) if block is None: return None ids = list(doc.get("정체") or []) was = {c: v.get("원값") for c, v in ((doc.get("원본") or {}).get("덮개") or {}).items()} lines = [] for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))): item = block["_items"][n] if n < len(block["_items"]) else {} qty = _f(x.get("수량")) head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""} if isinstance(item.get("참조"), dict) and depth < refs.DEPTH: line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head} elif isinstance(item.get("고정"), dict): line = {**fixed_line(item["고정"], qty), **head} elif qty and ("단가" in item or item.get("값묶음")): own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART} line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}} poom = was.get(ids[n]) if n < len(ids) else None lines.append({**line, "sk": True, **({"poom": poom} if poom else {})}) block.pop("_items") name = doc.get("이름") or block["name"] origin = {**block["origin"], "단가산출": rid} cover = f"P:{_digest({'단가산출': rid})}" return self.put({**block, "name": name, "cite": f"단가산출 {ref.get('키')}", "origin": origin, "lines": lines}, cover) # fmt: skip def _engine_line( self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None ) -> dict[str, Any]: head = {"name": x.get("이름") or "", "spec": "", "unit": x.get("단위") or ""} qty = _f(x.get("수량")) goods = x.get("품목") or {} if item is None: # 덧줄 — 밑수 × % / 100(% = 엔진 값 ÷ 엔진 밑수) add = (logic.get("덧줄") or [])[extra] base, label = _base_of(str(add.get("식") or "")) value, under = _f(x.get("금액")), bases.get(base) if base else None if value is None or not under: # ponytail: 밑수 없는 덧줄(손료 식 등)은 B09 단가로 못 셈 — 줄만 두고 빈칸 return {**head, "kind": "blank", "qty": qty, "why": "덧줄 — 대가 단가로 못 셈"} part = PART.get(str(add.get("비목")), "expense") return {**head, "kind": "pct", "part": part, "base": base, "spec": f"{label}의 %", "unit": "%", "qty": round(value / under * 100, 6)} # fmt: skip kind = item.get("종류") if kind == "인력": key = goods.get("키") or str(item.get("요소") or "") return {**head, "kind": "labor", "key": key, "qty": qty} if kind == "재료": if not goods.get("키"): return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"} return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "", "qty": qty} # fmt: skip # 로직 줄 — `Sangeun.call_of` 판정: 기계경비 로직 = 중기 · 그 밖 = 하위 산근 if call and call.get("machine"): return {**head, "kind": "machine", "key": call["machine"], "qty": qty} if call and call.get("code"): key = sg.sangeun_key(call["code"], call["given"]) return {**head, "kind": "sangeun", "key": key, "qty": qty} return {**head, "kind": "blank", "qty": qty, "why": "하위 로직 줄 — 못 펼침"} # ── 조합 · 사전조합 줄 ────────────────────────────────────────────── def line_of( self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0 ) -> dict[str, Any]: """담은 로직 줄 하나 → 블록 줄 — 대가 줄 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫) · 산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위).""" q = _f(qty) t = _f(times) or 1.0 each = None if q is None else q / t if fixed.is_fixed(one): return fixed_line(one[fixed.FIXED], q) ref = one.get("참조") if isinstance(ref, dict): doc = copies.get(refs.ref_id(ref)) if not isinstance(doc, dict): return { "kind": "blank", "name": name, "qty": each, "why": f"없는 참조 「{ref.get('키')}」", } if refs.kind_of(ref.get("키")) == "sangeun": # 단가산출(719-2) = 굳힌 줄 대가 bid = self.sangeun(whole, ref, doc, copies, depth + 1) if bid is None: why = "단가산출을 못 셈(바탕 없음 · 순환)" return {"kind": "blank", "name": name, "qty": each, "why": why} from M02_MasterTemplete.M02_Sangeun_Calc import times_of block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위 per = float(times_of(whole.logic(doc["바탕"]["로직"]))) return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], "qty": None if each is None else each / per} # fmt: skip if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가 first = (doc.get("담은로직") or [{}])[0] bid = self.poomsem(whole, first, doc.get("이름") or name) if bid is None: return {"kind": "blank", "name": name, "qty": each, "why": "돈 아닌 로직"} return {"kind": "daega", "key": bid, "name": self.blocks[bid]["name"], "unit": doc.get("단위") or "", "qty": each} # fmt: skip bid = self.precombo(whole, ref, doc, copies, depth + 1) return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, "unit": doc.get("단위") or "", "qty": each} # fmt: skip code = str(one.get("로직") or "") if one.get("승격") is not True: # 단가산출 줄 = 산근 if not sg.is_money(whole, code): return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"} key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) return {"kind": "sangeun", "key": key, "name": name or code, "unit": sg.unit_of(whole, code), "qty": q} # fmt: skip bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name) if bid is None: return { "kind": "blank", "name": name or code, "qty": each, "why": "돈 아닌 로직 · 못 돈 로직", } block = self.blocks[bid] return {"kind": "daega", "key": bid, "name": block["name"], "spec": block["spec"], "spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2).""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈 pills = { code: pill for pill in (kept if isinstance(kept, list) else None) or combo_store.combo_pills({"담은로직": inner})["물량"] for code in pill["로직"] } lines = [] for one in inner: hand = _f((one.get("수량") or {}).get("손")) pill = pills.get(one.get("로직")) or {} times = pill.get("배") if "로직" in one else 1 name = pill.get("이름") or str(one.get("로직") or "") if depth >= refs.DEPTH and isinstance(one.get("참조"), dict): lines.append({"kind": "blank", "name": name, "qty": hand, "why": "참조 깊이 넘음"}) continue qty = 1 if hand is None else hand lines.append(self.line_of(whole, one, qty, times, name, copies, depth)) lines += [ _material(whole, mat, (mat.get("수량") or {}).get("손")) for mat in doc.get("자재") or [] ] return self.put( { "kind": "사전조합", "name": doc.get("이름") or ref.get("키"), "spec": LAYER_WORD.get(str(ref.get("층")), ""), # 번호(키)는 풍선 글로만 "spec_raw": str(ref.get("키") or ""), "unit": doc.get("단위") or "", "cite": "", "origin": {"참조": refs.ref_id(ref)}, "lines": lines, } ) # ── 조합(구조물 사본) ─────────────────────────────────────────────── def combo(self, folder: Path, cover: str | None = None) -> str | None: labor = read_json(folder / LABOR_FILE) if not isinstance(labor, dict): return None prices = read_json(folder / PRICE_FILE) or {} values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} whole = item_prices.load_prices(prices) result = item_prices.calc(labor, prices, values, picks, read_json(folder / AREA_FILE)) doc = item_prices.with_values(labor.get("줄") or {}, values, picks) copies = labor.get("참조사본") or {} lines = [] for one, done in zip(doc.get("담은로직") or [], result.get("줄") or []): name = done.get("이름") or str(one.get("로직") or "") pill = next( (p for p in result.get("알약") or [] if p.get("키") == done.get("알약")), {} ) line = self.line_of( whole, one, done.get("수량"), done.get("배"), pill.get("이름") or name, copies ) if line.get("qty") is None and line["kind"] != "blank": line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} lines.append(line) lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []] manifest = read_json(folder / MANIFEST_NAME) or {} origin = manifest.get("원본") or {} return self.put( { "kind": "조합", "name": origin.get("name") or " ".join(origin.get("경로") or []) or folder.name, "spec": " › ".join(origin.get("경로") or []), "unit": (labor.get("줄") or {}).get("단위") or "", "cite": "", "origin": {k: origin.get(k) for k in ("layer", "owner", "key")}, "lines": lines, }, cover or f"S:{folder.name}", ) def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" if manifest.get("종류") not in ("precombo", "sangeun"): return self.combo(folder, f"L:{folder.name}") 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 {}) if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2) return self.sangeun(whole, ref, doc, copies, 1) return self.precombo(whole, ref, doc, copies, 1) def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: """고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 · 비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율).""" if rate := fx.get("비율"): name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"} return {**name, "kind": "pct", "part": PART[rate["비목"]], "base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip link = fx.get("이음") or {} return { "kind": "fixed", **{k: fx.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}, "qty": _f(qty), "own": {PART[k]: _f(v) for k, v in (fx.get("단가") or {}).items() if k in PART}, "link": { "labor": link.get("노임"), "material": link.get("자재"), "machine": link.get("기계"), }, } def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))} return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)} def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: """엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`.""" out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()} out["lines:labor"] = 0.0 for x, item in zip(engine, items): for name, value in (x.get("비목") or {}).items(): if name in PART: out[f"part:{PART[name]}"] += _f(value) or 0 if item.get("종류") == "인력": out["lines:labor"] += _f(x.get("금액")) or 0 if x.get("이름"): out[f"line:{x['이름']}"] = _f(x.get("금액")) or 0 return out def _base_of(formula: str) -> tuple[str | None, str]: """덧줄 식 → (밑수 갈래, 이름) — 모르는 꼴이면 (None, "").""" if hit := BASE_PART.match(formula): return f"part:{PART[hit.group(1)]}", hit.group(1) if hit := BASE_LINE.match(formula): return f"line:{hit.group(1)}", hit.group(1) if BASE_LABOR.match(formula): return "lines:labor", "인건비" return None, "" def _plain(value: Any) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, dict): return {k: _plain(v) for k, v in value.items()} if isinstance(value, list): return [_plain(v) for v in value] return value def cover_count(project_root: str | Path, folder: str) -> int: """702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록에 붙은 B09 고친 칸 수(카드 = 사본 셈).""" from B09_Estimation.B09_Estimation_Store import read_user cover = read_user(project_root).get("daega_cover") or {} if not cover: return 0 got = daega_inputs( project_root ) # ponytail: 프로젝트 한 벌 셈 — 카드 열 때만 · 느리면 사본 하나로 by_id = {b["id"]: b for b in got["blocks"]} owners = {linked["폴더"]: o for o, linked in linked_snapshots(project_root).items()} todo, seen = [got["owners"].get(owners.get(folder, ""))], set() while todo: block = by_id.get(todo.pop()) if block is None or block["id"] in seen: continue seen.add(block["id"]) todo += [ln.get("key") for ln in block["lines"] if ln["kind"] == "daega"] return sum(len(cover.get(by_id[bid]["cover"]) or {}) for bid in seen) def daega_inputs(project_root: str | Path) -> dict[str, Any]: """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 · 설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}. 차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례. """ base = structures_dir(project_root) build = Builder() owners: dict[str, str] = {} for owner, linked in sorted(linked_snapshots(project_root).items()): if "일위대가" not in linked.get("부분", []): continue try: bid = build.combo(base / linked["폴더"]) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): 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"]]) out = {"blocks": blocks, "owners": owners} return _plain({**out, "libs": libs} if libs else out)