diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 9d69ffbd..7f07ef7d 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -1,17 +1,20 @@ -"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종). +"""B09 원가계산 — 이중계상 감시 (거울 테스트 4종). **왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안, 「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서 위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는 검사**를 두어, 규칙을 어기면 계산이 멈추게 한다. -세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢) +네 규칙 (㉠㉡㉢ 원문 = PLAN 8-7 · ㉣ = PLAN 9-6, 2026-09-07 구현 중 발견) ㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다 (품셈 1-3-1 「할증 중복 적용 금지」). ㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미 포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6). ㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래 분해는 일위대가에서 한 번만 한다. + ㉣ **작업효율 `E` 를 두 곳에 넣지 않는다.** 품셈은 작업량 산정에 넣는다 + (`Q = n·q·f·E`, 8-1-4). 시간당 사용료는 1일 8시간으로 나눈다(8-1-6). + 실무 관측이 사용료 쪽에 미리 넣어 두어, 그 값을 보고 나눗수를 고치는 사고가 난다. """ from __future__ import annotations @@ -109,3 +112,44 @@ def check_mix_decomposed_once( f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 " "쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)." ) + + +def reject_efficiency_in_hourly_rate(factor: Decimal | None, *, where: str) -> None: + """㉣ 작업효율 `E`(실작업시간율)가 **시간당 사용료 쪽**에 들어오면 멈춘다. + + 품셈은 작업효율을 **작업량 산정**에 넣는다 — `Q = n·q·f·E` (8-1-4). + 시간당 사용료는 **1일 8시간**으로 나눈다 (8-1-6 「관리비는 1일 8시간 초과해도 + 8시간으로 계산」). **양쪽에 다 넣으면 같은 효율이 두 번 곱해진다.** + + 실무 관측(STC 2024)은 효율을 사용료 쪽에 미리 넣어 굴착기 0.7㎥ 노무가 + 55,700 원/hr(일당 ÷ 약 5.09시간)로 나온다. 그 값을 보고 「우리가 틀렸다」며 + 나눗수를 고치는 것이 정확히 이 자리에서 일어난다. + """ + if factor is None: + return + if Decimal(0) < Decimal(str(factor)) < Decimal(1): + raise DoubleCountError( + f"{where}: 작업효율({factor})이 시간당 사용료 계산에 들어왔습니다 — " + "효율은 작업량 산정(Q = n·q·f·E)에만 넣습니다. 양쪽에 넣으면 두 번 곱해집니다 " + "(PLAN 9-6 ㉣)." + ) + + +def check_operator_hours_basis( + *, + labor_per_hour: Decimal, + daily_wage: Decimal, + person_days: Decimal = Decimal(1), + hours_per_day: int = 8, +) -> None: + """㉣ 보조 — 조종원 노임 나눗수가 8시간인가. + + 나눗수를 몰래 줄이는 것이 곧 효율을 사용료에 넣는 것과 같다. + """ + expected = daily_wage * person_days / Decimal(hours_per_day) + if abs(labor_per_hour - expected) > _TOLERANCE: + raise DoubleCountError( + f"조종원 시간당 노무비 {labor_per_hour:,.2f} 가 " + f"{daily_wage:,.0f} × {person_days} ÷ {hours_per_day}h = {expected:,.2f} 와 다릅니다 — " + "나눗수를 줄이면 작업효율을 사용료에 넣은 것이 됩니다 (PLAN 9-6 ㉣)." + ) diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index d9f71888..21fdb3a2 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -27,6 +27,10 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any +from B09_Estimation.B09_Estimation_Guards import ( + check_operator_hours_basis, + reject_efficiency_in_hourly_rate, +) from B09_Estimation.B09_Estimation_PriceBook import Money3 _CATALOG_SUBPATH = ("resources", "data_cost_input_value") @@ -160,6 +164,7 @@ def hourly_machine_cost( fuel_price_per_liter: Decimal | None = None, operator_daily_wage: Decimal | None = None, operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY, + efficiency_factor: Decimal | None = None, ) -> HourlyMachineCost: """시간당 사용료 한 시간분. @@ -167,6 +172,11 @@ def hourly_machine_cost( 0 으로 때우지 않고 `gaps` 에 적어 돌려준다** — 빠진 채로 총액에 섞이는 것이 이 자리에서 제일 위험하다. """ + # ㉣ 작업효율이 이 자리에 들어오면 멈춘다 — 효율은 작업량 산정에만 들어간다. + reject_efficiency_in_hourly_rate( + efficiency_factor, where=f"{machine.display_name} 시간당 사용료" + ) + gaps: list[str] = [] expense = hourly_loss_cost(machine) @@ -183,6 +193,12 @@ def hourly_machine_cost( gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음") else: labor = operator_daily_wage / Decimal(operator_hours_per_day) + # ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다. + check_operator_hours_basis( + labor_per_hour=labor, + daily_wage=operator_daily_wage, + hours_per_day=operator_hours_per_day, + ) return HourlyMachineCost( machine=machine, diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py new file mode 100644 index 00000000..19c6a8b2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -0,0 +1,232 @@ +"""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, + )