Files
Aislo/B09_Estimation/B09_Estimation_UnitPrice.py
T
eomsangdonandClaude Opus 5 856be1f45b feat(B09): ③단가산출·일위대가 조립 + 네 번째 이중계상 규칙(㉣) 가드
**㉣ 작업효율을 두 곳에 넣지 않는다 — 계획서에 없던 네 번째 규칙**
- 품셈은 작업효율 `E` 를 **작업량 산정**에 넣음(`Q = n·q·f·E`, 8-1-4). 시간당 사용료는
  **1일 8시간**으로 나눔(8-1-6 「관리비는 1일 8시간 초과해도 8시간으로 계산」).
  **양쪽에 넣으면 같은 효율이 두 번 곱해짐.**
- 실무 관측이 효율을 사용료 쪽에 미리 넣어 두어(굴착기 0.7㎥ 노무 55,700/hr =
  일당 ÷ 5.09h), 그 값을 보고 나눗수를 고치는 사고가 정확히 이 자리에서 남.
- `reject_efficiency_in_hourly_rate()` · `check_operator_hours_basis()` 추가하고
  **`hourly_machine_cost()` 안에서 실제로 호출** — 함수만 있고 안 부르면 없는 것과 같음.
- PLAN 9-6 의 금지 규칙을 셋 → **넷**으로 늘리고 근거·경위 기록.
  기계 사용료 11 % 차이는 **「설명된 차이」**임을 표로 못 박음(맞추려 손대지 말 것).

**`B09_Estimation_UnitPrice.py` 신설 — 층을 실제로 쌓음**
- `S 취득가 · L 노임 · M 연료 → X 시간당 사용료 → B 일위대가`.
  **시간당 사용료를 미리 계산해 넣지 않음** — 쌓아야 화면이 「무엇으로 이루어졌나」를
  보일 수 있음(8-13 계산 과정을 감추지 않음).
- 조종원 수량 = `인/일 ÷ 8` 로 상세에 들어가 ㉣이 데이터로도 드러남.
- 자원이 하나도 안 붙은 공종은 **빈 줄로 안 세움** — 0 원 일위대가가 내역에 서면 안 됨.
- ㉠ 가드를 `verify_surcharge_once()` 로 실제 호출 — 일위대가 재료비는 **할증 전** 값.

산출 — 제목 **189**(일위대가 64 · 기계 층 3 · 노임·연료), 건너뛴 공종 0.
실측 `X-0201-0070` 굴착기 0.7㎥ = 재 26,130 + 노 35,412 + 경 24,554.

자체검증 — 신규 5건 포함 `pytest tmp/tests/ -q` **116 passed** · ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:58:11 +09:00

233 lines
8.6 KiB
Python

"""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,
)