"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체). 지금까지 안 내던 일곱 표 중 여섯이 여기서 난다. A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고 A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고 A7 재료비목록표 〃 A8 경비목록표 〃 (기계 취득가 `S-` 층이 여기 온다) A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고 — 노무비·재료비·경비·중기 네 벌 **서식은 지어내지 않았다** — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 그대로 옮겼다(2026-09-09 실측). 칸 이름·차례가 그 시트와 같다. ⚠ **새 계산이 아니다.** 목록표는 `PriceBook` 의 제목을 종류별로 늘어놓는 것이고, 집계표는 **내역서에 이미 선 금액을 자원별로 되모으는 것**이다. 값을 여기서 다시 만들면 내역서와 어긋난다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」). ⚠ **집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라 **두 표의 합이 원 단위로 어긋나는 것이 정상**이다 — 그 사실을 화면에 함께 낸다. """ from __future__ import annotations from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_PriceBook import PriceKind from B09_Estimation.B09_Estimation_Rounding import ( SUMMARY_MISMATCH_NOTE, OutputPlace, round_at, ) from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build _ZERO = Decimal(0) #: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다. LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = ( ("labor", "노무비목록표", PriceKind.LABOR), ("material", "재료비목록표", PriceKind.MATERIAL), ("expense", "경비목록표", PriceKind.MACHINE_BASE), ) def _money(value: Decimal | None) -> str | None: return None if value is None else str(value) def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]: """목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다. ⚠ 단가가 안 선 줄도 **빼지 않는다.** 빼면 「없는 것」과 「값을 못 구한 것」이 같아 보인다. """ rows: list[dict[str, Any]] = [] for code in sorted(build.book.titles): title = build.book.titles[code] if title.kind is not kind: continue try: price: Decimal | None = title.adopted_price() note = "" except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다 price, note = None, str(error) rows.append( { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "unit_price_krw": _money(price), "note": note, } ) return rows def machine_base_list() -> list[dict[str, Any]]: """경비목록표 — **기계 취득가격(천원)** 목록. ⚠ 내 `S-` 층과 **다른 값**이다. `S-` 는 「취득가 × 시간당 손료계수」라 **원/시간**이고, 실무 경비목록표는 **취득가 그 자체를 천원 단위**로 싣는다(영월 실측: `S00104 불도저(무한궤도) 19톤 **천원** 184,499`). 손료를 여기 실으면 자릿수가 세 자리 어긋난 채 「경비」로 읽힌다. """ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog catalog = load_machine_catalog() rows: list[dict[str, Any]] = [] for code in sorted(catalog.machines): machine = catalog.machines[code] rows.append( { "code": f"S-{code}", "name": machine.name, "spec": machine.specification, "unit": "천원", "unit_price_krw": _money(machine.price_thousand_krw), "note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보", } ) return rows def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]: """중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸). 실무 서식: `X00205 굴삭기(무한궤도) 0.7㎥ 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128` """ rows: list[dict[str, Any]] = [] for code in sorted(build.book.titles): title = build.book.titles[code] if title.kind is not PriceKind.MACHINE_HOURLY: continue try: money = build.book.resolve(code) row = { "total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), "labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)), "material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)), "expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)), "note": "", } except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다 row = { "total_krw": None, "labor_krw": None, "material_krw": None, "expense_krw": None, "note": str(error), } rows.append( {"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row} ) return rows def resource_summary( quantities: dict[str, Decimal], build: UnitPriceBuild | None = None, ) -> dict[str, Any]: """자원 집계표 — 공종 수량을 **자원별로 되모은다**. `quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양). 한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다. ⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가 실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 이중으로 세는 것**이 된다. """ prices = build or cached_build() book = prices.book #: 자원코드 → [수량, 제목] picked: dict[str, list[Any]] = {} missing: list[str] = [] for raw_code, quantity in quantities.items(): code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" if code not in book.titles: missing.append(raw_code) continue amount = Decimal(str(quantity)) for detail in book.details.get(code, []): if ( detail.percent_of_labor is not None or detail.percent_of_parent is not None or detail.percent_of_material is not None ): continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다 ref = detail.ref_code if ref == code: continue slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)]) slot[0] += detail.quantity * amount groups: dict[str, list[dict[str, Any]]] = { "labor": [], "material": [], "expense": [], "machine": [], } for ref, (amount, title) in sorted(picked.items()): if title is None: missing.append(ref) continue bucket = { PriceKind.LABOR: "labor", PriceKind.MATERIAL: "material", PriceKind.MACHINE_BASE: "expense", PriceKind.MACHINE_HOURLY: "machine", }.get(title.kind) if bucket is None: continue try: unit_money = book.resolve(ref) unit_price: Decimal | None = unit_money.total # ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다. money: Decimal | None = round_at( unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY ) note = "" except Exception as error: unit_price, money, note = None, None, str(error) groups[bucket].append( { "code": ref, "name": title.name, "spec": title.spec, "quantity": str(amount), "unit": title.unit, "unit_price_krw": _money(unit_price), "amount_krw": _money(money), "note": note, } ) return { "groups": groups, "missing": sorted(set(missing)), "note": SUMMARY_MISMATCH_NOTE, } def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]: """목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다.""" prices = build or cached_build() return { "labor": catalog_list(prices, PriceKind.LABOR), "material": catalog_list(prices, PriceKind.MATERIAL), "expense": machine_base_list(), "machine": machine_list(prices), }