"""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 B09_Estimation.B09_Estimation_Guards import 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, ) _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 원 일위대가가 내역에 서면 안 된다. """ if axis is None: axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) 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 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=work_item_code, unit=unit) ) added = 0 for row in rows: ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" if ref not in build.book.titles: continue build.book.add_detail(PriceDetail(title_code, ref, row.amount)) added += 1 if added == 0: build.skipped.append(work_item_code) 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, )