fix(b09): 중기 호표 잡품을 주연료비 × 율 가산 행으로·성분 소계 절사 — 골든셋 중기 143 호표 전수

- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(STmate 잡품 단가 칸 = 연료 금액)
- 시간당 중기사용료(X) 호표 — 줄 금액 0.1원 미만 · 성분 소계 원 미만 절사(명세 7장, 봉화 0.7㎥ 96,843)
- 조종원 시간당 노임 자르는 자리를 프로젝트 설정 operator_wage_digits 로(기본 0, 화면은 뒤)
- 골든셋 ④ 벌 — 실무 6건 중기사용료 시트 143 호표를 우리 단가표 층으로 되풀어 전수 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 23:29:16 +09:00
co-authored by Claude Opus 5
parent 7d7b9cf8fa
commit 50c3fb65f5
8 changed files with 207 additions and 25 deletions
+53 -11
View File
@@ -94,6 +94,8 @@ def _slots(value: Decimal) -> list[Decimal | None]:
@dataclass
class UnitPriceBuild:
book: PriceBook = field(default_factory=PriceBook)
#: 조종원 시간당 노임을 자른 자리(프로젝트 설정 `operator_wage_digits`, 기본 0 = 원 미만).
operator_wage_digits: int = 0
#: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다.
skipped: list[str] = field(default_factory=list)
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
@@ -244,8 +246,26 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i
return swapped
#: 중기 호표 잡품 줄 이름표 — 화면이 「공구손료·잡재료(1-2-6)」와 가르는 데 씀.
MISC_NOTE_PREFIX = "잡품"
def _misc_row(hourly_code: str, percent: Decimal) -> PriceDetail:
"""중기 호표 **잡품** — 주연료비의 % 가 재료비로 붙는 가산 행(자기 호표를 가리키는 비율 줄)."""
return PriceDetail(
hourly_code,
hourly_code,
Decimal(0),
note=f"{MISC_NOTE_PREFIX} — 주연료비 × {percent}%",
percent_of_material=percent,
)
def _add_machine_layers(
book: PriceBook, machine_codes: set[str], fuel_region: str | None = None
book: PriceBook,
machine_codes: set[str],
fuel_region: str | None = None,
operator_wage_digits: int = 0,
) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -353,10 +373,11 @@ def _add_machine_layers(
liters = record.fuel_liters_per_hour
if liters is not None:
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료"))
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="주연료 + 잡재료"))
# 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료
# 금액」) — 연료 수량에 접지 않음. 유가와 같이 움직이는 것은 그대로.
book.add_detail(_misc_row(hourly_code, record.misc_material_percent))
# 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다.
# 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤).
@@ -378,11 +399,11 @@ def _add_machine_layers(
PriceDetail(
combined_code,
fuel_code,
record.fuel_liters_per_hour
* (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)),
note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)",
record.fuel_liters_per_hour,
note="주연료 (조합 사용)",
)
)
book.add_detail(_misc_row(combined_code, Decimal(COMBINED_MISC_PERCENT)))
else:
incomplete.append(f"{code} (연료소모량 없음)")
@@ -402,12 +423,18 @@ def _add_machine_layers(
kind=PriceKind.LABOR,
name="조종원(시간당)",
unit="hr",
slots=_slots(hourly_operator_wage(wages[wage_code])),
slots=_slots(
hourly_operator_wage(wages[wage_code], digits=operator_wage_digits)
),
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
reliability=load_labor_reliability().get(wage_code, ""),
)
)
wage_code = hourly_wage_code
cut = (
"원 미만" if operator_wage_digits == 0 else f"소수 {operator_wage_digits}자리 아래"
)
operator_note = f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · {cut} 절사)"
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합"
if combined_code in book.titles:
@@ -416,7 +443,7 @@ def _add_machine_layers(
combined_code,
wage_code,
per_hour_person,
note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
note=operator_note,
)
)
book.add_detail(
@@ -424,7 +451,7 @@ def _add_machine_layers(
hourly_code,
wage_code,
per_hour_person,
note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
note=operator_note,
)
)
else:
@@ -580,6 +607,7 @@ def build_unit_prices(
transport_distance_km: Decimal | None = None,
transport_road: str | None = None,
labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0,
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -599,6 +627,9 @@ def build_unit_prices(
⚠ `labor_surcharge_choices` — 품의 할인·할증 26계열(산림품셈 1-4). **안 고르면 안 붙는다.**
어느 공종에 붙일지는 원문 [주]가 작업을 지목하므로 **켜는 것은 사용자 몫**이다.
⚠ `operator_wage_digits` — 조종원 시간당 노임을 자르는 자리(0 = 원 미만 절사). 실무마다 다름
(다섯 건 0 · 2025 울진 소광 1) — 규정이 없는 자리라 프로젝트 설정 칸(브레인 판정 2026-09-13).
"""
from B09_Estimation.B09_Estimation_FactorChoices import (
chosen_values,
@@ -731,7 +762,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region)
build.operator_wage_digits = operator_wage_digits
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
@@ -1116,6 +1150,7 @@ def cached_build(
transport_distance_km: str = "",
transport_road: str = "",
labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "",
) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1142,9 +1177,16 @@ def cached_build(
transport_distance_km=parse_distance_km(transport_distance_km),
transport_road=transport_road or None,
labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
)
def parse_operator_wage_digits(value: Any) -> int:
"""조종원 시간당 노임 자르는 자리 설정 — 0·1·2 만 받고 그 밖(빈 값 포함)은 기본 0."""
text = str(value if value is not None else "").strip()
return int(text) if text in ("0", "1", "2") else 0
@dataclass
class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**.