"""B09 원가계산 — **단계 합산형 부모**를 잎들의 합으로 조립한다 (명세 2장 · 2026-09-13 축 C Ⓐ). 공종 마스터가 부모마다 `parent_mode` 를 단다(`B08_Quantity_Build_WorkItemMaster_Parents`). choose_one 갈래 고르기형 — **여기서 안 세운다.** 내역 줄이 부모를 가리키면 오류로 드러냄 sum_steps 단계 합산형 — B-<부모>[#갈래] = Σ B-<잎>[#갈래] × 가중치 ⭐ 흙깎기(리핑암) = 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 가 첫 사례다 — 부모에 일위대가 제목이 아예 없어 금액 0 이던 자리(명세 2장 실측). ⚠ **암질 갈래** — 암파쇄(9-4-1)·깎기(9-5-2) 표는 연암·보통암·경암마다 작업능력 `Q`(㎥/hr)를 준다. 갈래마다 따로 세우고, 부모도 그 갈래로 선다. 내역 줄의 암질이 표에 없으면(풍화암·암 등) **원문이 평균을 정한 잎만** 평균으로 선다(9-4-1 [주]①). 9-5-2 는 그 [주]가 없어 막는다. ⚠ 모르는 값을 지어내지 않는다 — 단계 하나라도 못 서면 부모를 안 세우고 **어느 단계가 왜**를 남긴다. """ from __future__ import annotations import re from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle ROCK_CLASSES = ("연암", "보통암", "경암") #: 암질을 표에 없는 이름으로 준 줄이 쓸 갈래 — **원문이 평균을 정한 잎만**. AVERAGE_VARIANT = "평균" AVERAGE_BASIS: dict[str, str] = { "FP-09-04-01": "품셈 9-4-1 [주]① 「Q(시간당 작업량 ㎥/hr) =(5.0+3.4+2.6)/3」 — 세 암질 평균", } #: 표에 기계 이름이 없는 잎 — [주]가 기종을 적은 자리(마스터가 [주]를 못 싣는다). 원문 그대로. #: ⚠ 부모(9-5)에서 물려받을 기계는 없다 — 부모엔 표가 없고, 잎의 [주]가 스스로 적었다. NOTE_MACHINES: dict[str, tuple[tuple[str, ...], str]] = { "FP-09-05-02": ( ("0201-0070", "0230-0007"), "품셈 9-5-2 [주] 「장비는 대형브레이커와 무한궤도 굴착기(0.7㎥)를 적용한다」", ), } def _tight(text: Any) -> str: return re.sub(r"\s", "", str(text or "")) def rock_rows(node: dict[str, Any]) -> list[tuple[str, Decimal, Decimal | None]]: """(암질, 작업능력 Q, 치즐소모량) — 암질 이름 칸 **바로 뒤** 두 칸을 읽는다.""" from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure found: list[tuple[str, Decimal, Decimal | None]] = [] for table in node.get("tables", []): for row in table.get("raw_row") or []: cells = [str(cell) for cell in row] index = next((i for i, c in enumerate(cells) if _tight(c) in ROCK_CLASSES), None) if index is None: continue tail = [parse_measure(c) for c in cells[index + 1 : index + 3]] + [None, None] if tail[0] is not None and tail[0] > 0: found.append((_tight(cells[index]), tail[0], tail[1])) return found def leaf_machines(node: dict[str, Any]) -> tuple[list[tuple[str, str]], str]: """(기종 코드·이름 목록, 출처) — 표의 조합 칸(「대형브레이커+ 유압식백호우 …」)이 먼저.""" from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine from B09_Estimation.B09_Estimation_MachineProductivity_Reference import _machine_by_name catalog = load_machine_catalog() for table in node.get("tables", []): for row in table.get("raw_row") or []: for cell in row: parts = [part.strip() for part in str(cell).split("+")] if len(parts) < 2: continue bodies = [resolve_machine(part) for part in parts] body = next((b for b in bodies if b is not None), None) if body is None: continue spec = str(catalog.machines[body[0]].specification) picked = [b or _machine_by_name(p, spec) for b, p in zip(bodies, parts)] if all(picked): return [ p for p in picked if p ], f"표의 조합 칸 「{' '.join(str(cell).split())}」" code = str(node.get("work_item_code") or "") if code in NOTE_MACHINES: codes, source = NOTE_MACHINES[code] return [(c, catalog.machines[c].name) for c in codes if c in catalog.machines], source return [], "" def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: """암질 갈래마다 잎 제목을 세운다 — 기계마다 1/Q 시간.""" code = str(node["work_item_code"]) rows = rock_rows(node) if not rows or any(title.startswith(f"B-{code}#") for title in build.book.titles): return machines, source = leaf_machines(node) missing = [f"X-{m}" for m, _ in machines if f"X-{m}" not in build.book.titles] if not machines or missing: build.component_gaps[code] = ( f"작업능력 표의 기종을 못 골랐습니다 — {source or '표·[주]에 기종 없음'}" + (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "") ) return variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows] if code in AVERAGE_BASIS: average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)) variants.append( (AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}") ) for variant, capacity, note in variants: title_code = f"B-{code}#{variant}" build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{node.get('name', code)} ({variant})", spec=variant, unit="㎥", ) ) for machine_code, _name in machines: # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). build.book.add_output_detail( title_code, f"X-{machine_code}", Decimal(1) / capacity, f"{note} · {source}" ) build.variants.setdefault(code, []).append(variant) # 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결). build.unattached[code] = ["치즐소모량(본/hr) — 자재 단가 층 없음"] if rows[0][2] else [] def _step_titles(build: Any, step: str) -> dict[str, str]: """갈래 → 그 단계의 제목 코드. 갈래 없는 제목은 빈 문자열 키.""" titles = {"": f"B-{step}"} if f"B-{step}" in build.book.titles else {} for code in build.book.titles: if code.startswith(f"B-{step}#"): titles[code.split("#", 1)[1]] = code return titles def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None: parent = str(node["work_item_code"]) steps = [(str(s["code"]), Decimal(str(s["weight"]))) for s in node.get("steps") or []] per_step: list[tuple[str, Decimal, dict[str, str]]] = [] for step, weight in steps: label = f"단계 {step} {names.get(step, '')}".strip() titles = _step_titles(build, step) if not titles: why = build.component_gaps.get(step) or "일위대가가 없습니다" build.component_gaps[parent] = f"{label} 이 안 섰습니다 — {why}" return if step in build.partial_ratio or step in build.basis_missing: why = build.component_gaps.get(step) or "일부 몫만 섰습니다" build.component_gaps[parent] = f"{label} 이 일부만 섰습니다 — {why}" return per_step.append((step, weight, titles)) varying = [item for item in per_step if set(item[2]) - {""}] if len(varying) > 1: build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다" return units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""} if len(units) > 1: build.component_gaps[parent] = f"단계 단위가 서로 다릅니다 — {', '.join(sorted(units))}" return variants = [v for v in varying[0][2] if v] if varying else [""] for variant in variants: title_code = f"B-{parent}" + (f"#{variant}" if variant else "") build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{names.get(parent, parent)} ({variant})" if variant else names.get(parent, parent), spec=variant or parent, unit=next(iter(units), ""), ) ) for step, weight, titles in per_step: ref = titles.get(variant) or titles[""] build.book.add_detail( PriceDetail( title_code, ref, weight, note=f"단계 합산 × {weight} — {node.get('steps_basis', '')}", ) ) if variant: build.variants.setdefault(parent, []).append(variant) if varying and AVERAGE_VARIANT in varying[0][2] and varying[0][0] in AVERAGE_BASIS: build.default_variants[parent] = (AVERAGE_VARIANT, AVERAGE_BASIS[varying[0][0]]) build.component_gaps.pop(parent, None) def attach_parent_steps(build: Any, master: dict[str, Any]) -> None: """부모 모양을 싣고, 합산형 부모를 조립한다 — `build_unit_prices` 끝(조합 16% 바꿔 달기 전).""" nodes = master.get("work_items", []) names = {str(n.get("work_item_code")): str(n.get("name", "")) for n in nodes} by_code = {str(n.get("work_item_code")): n for n in nodes} for node in nodes: mode = node.get("parent_mode") if mode: build.parent_modes[str(node["work_item_code"])] = str(mode) if mode != "sum_steps": continue for step in node.get("steps") or []: leaf = by_code.get(str(step["code"])) if leaf is not None: _add_rock_leaf(build, leaf) _assemble(build, node, names)