"""B09 원가계산 — ③ 단가산출·일위대가 조립 (PLAN 9-3 · 9-5). 자원 축(`resource_axis`)이 「이 공종 1단위에 무엇이 얼마나」를 갖고 있고, 카탈로그가 「그 자원 하나가 얼마」를 갖고 있다. 이 모듈이 둘을 곱해 **일위대가 한 줄**을 만든다. 층은 그대로 쌓는다 (PLAN 9-3): S 취득가 · L 노임 · M 자재 → X 시간당 중기사용료 → B 일위대가 `PriceBook` 에 제목·상세로 앉히므로 **표를 따로 만들지 않는다.** **부르는 가드** (함수만 있고 안 부르면 없는 것과 같다) - ㉠ 자재는 **할증 전** 값 — 할증은 자재총괄 한 곳뿐 (`check_surcharge_once`). - ㉣ 작업효율은 사용료 쪽에 안 넣음 (`reject_efficiency_in_hourly_rate`, `B09_Estimation_MachineCost` 안에서 호출됨). """ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from functools import lru_cache from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, load_operating_records, load_operator_wages, ) from B09_Estimation.B09_Estimation_PriceBook import ( PriceBook, PriceDetail, PriceKind, PriceTitle, ) from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, build_resource_axis, load_combined_catalog, load_labor_catalog, load_work_item_master, ) from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" def _slots(value: Decimal) -> list[Decimal | None]: """6번(적용 단가) 슬롯에만 값을 넣는다 — 유료 물가지 미구독 상태의 기본 모양.""" slots: list[Decimal | None] = [None] * 6 slots[5] = value return slots @dataclass class UnitPriceBuild: book: PriceBook = field(default_factory=PriceBook) #: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다. skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). incomplete_machines: list[str] = field(default_factory=list) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: catalog = load_labor_catalog() for entry in catalog.entries: wage = wages.get(entry.code) if wage is None or entry.code in book.titles: continue book.add_title( PriceTitle( code=entry.code, kind=PriceKind.LABOR, name=entry.name, unit="인", slots=_slots(wage), ) ) def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. 시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이 「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음). """ catalog = load_machine_catalog() operating = {r.machine_code: r for r in load_operating_records().records} fuel_price, _ = load_fuel_price() wages = load_operator_wages() incomplete: list[str] = [] fuel_code = f"{FUEL_CODE_PREFIX}경유" if fuel_code not in book.titles: book.add_title( PriceTitle( code=fuel_code, kind=PriceKind.MATERIAL, name="경유", unit="L", slots=_slots(fuel_price), ) ) for code in sorted(machine_codes): machine = catalog.machines.get(code) record = operating.get(code) if machine is None or machine.loss_coefficient_per_hour is None or record is None: incomplete.append(code) continue base_code = f"S-{code}" hourly_code = f"X-{code}" if hourly_code in book.titles: continue # S — 취득가에서 나온 시간당 손료. 경비 성분만 갖는다. book.add_title( PriceTitle( code=base_code, kind=PriceKind.MACHINE_BASE, name=machine.name, spec=machine.specification, unit="hr", slots=_slots( machine.price_thousand_krw * Decimal(1000) * machine.loss_coefficient_per_hour ), ) ) book.add_title( PriceTitle( code=hourly_code, kind=PriceKind.MACHINE_HOURLY, name=machine.name, spec=machine.specification, unit="hr", ) ) book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료")) liters = record.fuel_liters_per_hour if liters is not None: if record.misc_material_percent is not None: # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) else: incomplete.append(f"{code} (연료소모량 없음)") wage_code = record.operator_occupation_code if wage_code and wage_code in wages and record.operator_person_days is not None: # ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다. per_hour_person = record.operator_person_days / Decimal(8) if wage_code not in book.titles: book.add_title( PriceTitle( code=wage_code, kind=PriceKind.LABOR, name="조종원", unit="인", slots=_slots(wages[wage_code]), ) ) book.add_detail( PriceDetail(hourly_code, wage_code, per_hour_person, note="조종원 (1일 8시간)") ) else: incomplete.append(f"{code} (조종원 없음)") return incomplete def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. """ master = load_work_item_master() if axis is None: axis = build_resource_axis(master, load_combined_catalog()) # 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다. names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() wages = load_operator_wages() _add_labor_titles(build.book, wages) machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} build.incomplete_machines = _add_machine_layers(build.book, machine_codes) by_item: dict[str, list] = {} for row in axis.rows: by_item.setdefault(row.work_item_code, []).append(row) for work_item_code, rows in sorted(by_item.items()): title_code = f"B-{work_item_code}" if title_code in build.book.titles: continue # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). attachable = [ (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") for row in rows ] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] if not attachable: build.skipped.append(work_item_code) continue unit = next((r.amount_unit for r in rows if r.amount_unit), "") build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=names.get(work_item_code) or work_item_code, spec=work_item_code, unit=unit, ) ) for row, ref in attachable: build.book.add_detail(PriceDetail(title_code, ref, row.amount)) return build def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal: """일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값.""" return build.book.resolve(code).material def verify_surcharge_once( build: UnitPriceBuild, code: str, *, material_summary_total: Decimal, surcharge_rate_percent: Decimal, ) -> None: """㉠ 자재총괄 합과 대조한다 — 할증이 두 번 붙었으면 여기서 멈춘다.""" check_surcharge_once( material_summary_total=material_summary_total, unit_price_material_total=material_total_before_surcharge(build, code), surcharge_rate_percent=surcharge_rate_percent, label=code, ) #: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축). SOURCE_INDEX: dict[PriceKind, int] = { PriceKind.MATERIAL: 5, PriceKind.LABOR: 6, PriceKind.MACHINE_BASE: 105, PriceKind.MACHINE_HOURLY: 105, PriceKind.UNIT_PRICE: 103, PriceKind.PRICE_BASIS: 104, PriceKind.LUMPSUM: 0, } SOURCE_LABEL: dict[PriceKind, str] = { PriceKind.MATERIAL: "자재", PriceKind.LABOR: "노임", PriceKind.MACHINE_BASE: "기계경비", PriceKind.MACHINE_HOURLY: "기계경비", PriceKind.UNIT_PRICE: "일위대가", PriceKind.PRICE_BASIS: "단가산출", PriceKind.LUMPSUM: "일식·견적", } #: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다. DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS}) @lru_cache(maxsize=1) def cached_build() -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.""" return build_unit_prices() def _status_notes() -> list[str]: """화면에 낼 「지금 무엇이 안 선 상태인가」. 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 거의 없다는 것이 이 자리의 진짜 공백이다. """ from B09_Estimation.B09_Estimation_MaterialCatalog import ( catalog_summary, load_material_catalog, ) summary = catalog_summary(load_material_catalog()) return [ f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", "**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — " "값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.", ( 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 return { "titles": len(build.book.titles), "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, # ⚠ 지금 상태를 화면에 그대로 알린다 (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) rows: list[dict] = [] for detail in build.book.details.get(code, []): 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, "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, }