"""B09 원가계산 — 일위대가 **화면용 조회** (요약·목록·본표). 조립(`B09_Estimation_UnitPrice`)과 **보여주기**를 갈라 둔 파일이다. 700줄 제한(CLAUDE.md 4장)에 걸려 나눴고, 가르는 금은 「값을 만드는가 / 만든 값을 화면 모양으로 옮기는가」다. ⚠ 단수 처리는 **여기서** 한다 — 계산 함수 안에서 자르지 않는다 (`B09_Estimation_Rounding` 머리말). 일위대가 금액란은 0.1원 버림이다. """ from __future__ import annotations import re from decimal import Decimal from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog from B09_Estimation.B09_Estimation_PriceBook import PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( DRILLABLE_KINDS, SOURCE_INDEX, SOURCE_LABEL, SUSPICIOUSLY_HIGH_KRW, SUSPICIOUSLY_LOW_KRW, UnitPriceBuild, ) from B09_Estimation.B09_Estimation_Guards import check_column_sums _ZERO = Decimal(0) _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") def _plain(text: str) -> str: """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" return _RE_EMPHASIS.sub(lambda match: match.group(1), text) def _status_notes() -> list[str]: """화면에 낼 「지금 무엇이 안 선 상태인가」. 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 거의 없다는 것이 이 자리의 진짜 공백이다. """ from B09_Estimation.B09_Estimation_MaterialCatalog import ( catalog_summary, load_material_catalog, ) summary = catalog_summary(load_material_catalog()) # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. return [ _plain(note) for note in [ f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", ( f"관급 자재 **설치 주체가 미지정**" f"({summary['owner_supplied_install_unspecified']:,}건)이라 " "안전관리비 대상액에 자동으로 넣지 않습니다." ), ] ] def build_summary(build: UnitPriceBuild) -> dict: """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" kinds: dict[str, int] = {} for title in build.book.titles.values(): kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). totals = sorted( build.book.resolve(code).total for code, title in build.book.titles.items() if title.kind is PriceKind.UNIT_PRICE ) stats: dict[str, str] = {} low: list[dict[str, str]] = [] if totals: stats = { "min": _money_text(totals[0]), "median": _money_text(totals[len(totals) // 2]), "max": _money_text(totals[-1]), } # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라 # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다. low = [ {"code": code, "name": title.name, "total": _money_text(money)} for code, title in build.book.titles.items() if title.kind is PriceKind.UNIT_PRICE and code[2:].split("#")[0] not in build.partial_ratio and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW ] # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다. high = [ {"code": code, "name": title.name, "total": _money_text(money)} for code, title in build.book.titles.items() if title.kind is PriceKind.UNIT_PRICE and not title.unit and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW ] return { "titles": len(build.book.titles), "unit_price_totals": stats, # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. "suspiciously_low": low, # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다. "blocked_items": len(build.partial_ratio), # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다. "unknown_basis_high": high, "unknown_basis": sum( 1 for code, title in build.book.titles.items() if title.kind is PriceKind.UNIT_PRICE and not title.unit ), "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), "skipped_work_items": len(build.skipped), "incomplete_machines": len(build.incomplete_machines), "kinds": kinds, # ⚠ 표본이 얇은 노임이 내역에 실렸으면 **화면이 말해야 한다** — 금액은 그대로 서고 # 「이 단가는 조사현장이 적다」만 알린다(지식DB `노임단가_적용 §2-3` # 「단가 채택 시 플래그 유지 필요」, 2026-09-09에 이음). "labor_reliability": [ {"code": code, "name": name, "flag": flag, "why": why} for code, (name, flag, why) in sorted(build.labor_reliability.items()) ], # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). "notes": _status_notes(), } def _money_text(value: Decimal) -> str: """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). """ return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) def list_unit_prices(build: UnitPriceBuild) -> list[dict]: """목록표 — 「무엇이 있나」 한 줄씩.""" rows: list[dict] = [] for code, title in sorted(build.book.titles.items()): if title.kind is not PriceKind.UNIT_PRICE: continue money = build.book.resolve(code) rows.append( { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "material": _money_text(money.material), "labor": _money_text(money.labor), "expense": _money_text(money.expense), "total": _money_text(money.total), } ) return rows def detail_of(build: UnitPriceBuild, code: str) -> dict: """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" title = build.book.title(code) money = build.book.resolve(code) # 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다. work_item_code = code[2:].split("#")[0] if code.startswith("B-") else "" from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note unattached = list(build.unattached.get(work_item_code, [])) rows: list[dict] = [] for detail in build.book.details.get(code, []): if detail.percent_of_labor is not None: # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야 # 화면 합계와 실제 단가가 어긋나지 않는다. # 밑수는 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다 # (근거 인용 셋은 `PriceBook.resolve` 의 같은 자리 주석). labor_so_far = sum( ( Decimal(str(row_item["labor"])) for row_item in rows if row_item.get("kind") == PriceKind.LABOR.value ), _ZERO, ) amount = labor_so_far * detail.percent_of_labor / Decimal(100) rows.append( { "code": detail.ref_code, "name": "제잡비", "spec": f"노무비의 {detail.percent_of_labor}%", "unit": "%", "quantity": str(detail.percent_of_labor), "material": "0", "labor": "0", "expense": str(amount), "total": _money_text(amount), "source": "품셈 [주]", "drillable": False, "note": detail.note, } ) continue if detail.percent_of_material is not None: # 공구손료·잡재료 — 지금까지 쌓인 **주재료비**의 %가 재료비로 붙는다 # (산림품셈 1-2-6). 밑수는 **자재 줄만** — 하위 일위대가가 품고 온 재료비는 # 그쪽에서 이미 셌다(`PriceBook.resolve` 의 같은 자리와 한 규칙). material_so_far = sum( ( Decimal(str(row_item["material"])) for row_item in rows if row_item.get("kind") == PriceKind.MATERIAL.value ), _ZERO, ) amount = material_so_far * detail.percent_of_material / Decimal(100) # 중기 호표의 잡품(주연료비 × 율)도 같은 비율 줄 — 이름표만 가름(품셈 제8장). misc = detail.note.startswith("잡품") rows.append( { "code": detail.ref_code, "name": "잡품" if misc else "공구손료·잡재료", "spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%", "unit": "%", "quantity": str(detail.percent_of_material), "material": str(amount), "labor": "0", "expense": "0", "total": _money_text(amount), "source": "품셈 제8장" if misc else "품셈 1-2-6", "drillable": False, "note": detail.note, } ) continue child = build.book.title(detail.ref_code) unit_money = build.book.resolve(detail.ref_code) line = unit_money.scaled(detail.quantity) rows.append( { "ref_code": detail.ref_code, "name": child.name, "spec": child.spec, "unit": child.unit, # 제잡비 밑수를 가릴 때 쓴다 — 사람 품(`labor`)만 센다. "kind": child.kind.value, "source_index": SOURCE_INDEX.get(child.kind, 0), "source_label": SOURCE_LABEL.get(child.kind, ""), "drillable": child.kind in DRILLABLE_KINDS, "quantity": str(detail.quantity), "unit_material": _money_text(unit_money.material), "unit_labor": _money_text(unit_money.labor), "unit_expense": _money_text(unit_money.expense), "unit_total": _money_text(unit_money.total), "material": _money_text(line.material), "labor": _money_text(line.labor), "expense": _money_text(line.expense), # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. "total": str( round_at(line.material, OutputPlace.UNIT_PRICE_ROW) + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) ), "note": detail.note, } ) # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. summed = { key: sum((Decimal(r[key]) for r in rows), Decimal(0)) for key in ("material", "labor", "expense", "total") } # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") return { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "kind": title.kind.value, "material": str(summed["material"]), "labor": str(summed["labor"]), "expense": str(summed["expense"]), "total": str(summed["total"]), # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. "precise_total": _money_text(money.total), "rows": rows, # ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다. # 안 보이면 조용히 싼 단가가 내역서에 그대로 든다. "unattached": unattached, # ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다. "known_gap_note": known_gap_note(work_item_code), "unattached_note": ( f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — " f"{', '.join(unattached[:4])}" + (" 등" if len(unattached) > 4 else "") + ". 자재·기계 카탈로그가 서면 채워집니다. 그때까지 이 단가는 " "**붙은 줄만의 값**입니다." ) if unattached else "", }