"""B09 일위대가(대가) 블록 입력(PLAN 145-2) — 서버 단독 · 읽기만 · 값(단가)은 안 씀. 연결된 사본(`M02_Item_Snapshot.linked_snapshots`)마다 `일위대가.json` 을 사본 셈 한 벌 (`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) (155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음) 같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · 줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 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_Sangeun as sg 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_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]) -> str: bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}" if bid not in self.blocks: self.blocks[bid] = {"id": bid, **block} return bid # ── 품셈(로직 하나) ───────────────────────────────────────────────── def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: 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())) return self.put( { "kind": "품셈", "name": title or logic.get("이름") or code, "spec": spec, "unit": unit, "cite": logic.get("출처") or "", "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, "lines": lines, } ) 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 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("키")) == "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(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"], "unit": block["unit"], "qty": each} # fmt: skip def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] pills = { code: pill for pill in 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)) return self.put( { "kind": "사전조합", "name": doc.get("이름") or ref.get("키"), "spec": str(ref.get("키") or ""), "unit": doc.get("단위") or "", "cite": "", "origin": {"참조": refs.ref_id(ref)}, "lines": lines, } ) # ── 조합(구조물 사본) ─────────────────────────────────────────────── def combo(self, folder: Path) -> 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) for mat in result.get("자재") or []: row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "", "spec": row.get("규격") or "", "unit": row.get("단위") or "", "qty": _f(mat.get("수량"))}) # fmt: skip 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, } ) 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 daega_inputs(project_root: str | Path) -> dict[str, Any]: """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 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 order = {"조합": 0, "사전조합": 1, "품셈": 2} blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]]) return _plain({"blocks": blocks, "owners": owners})