fix(b09): 조종원 시간당 노임을 식 좌→우 순차 + 절사로 — 골든셋 시험 첫 벌

- 계수로 미리 접으면 267,360 → 55,699 로 1원 틀림 — 식 1/8*16/12*25/20 을 차례로 풀어 55,700
- 중기 호표 운전원 줄은 시간당 노임 제목(#시간) × 인수로 부름(STmate 운전원 1 × 55,700 모양)
- 골든셋 시험 test_b09_golden_stmate — 실무 6건 환율및기초자료 시간당 칸 전수 재현
  (다섯 건 원 미만 절사 · 2025 울진 소광 0.1원 미만 절사 — 자리는 설정값)
- 28자리 끝 반올림 차례만 다른 합 비교 시험을 허용 오차로

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:13:20 +09:00
co-authored by Claude Opus 5
parent 165bfed0a8
commit fd30227b73
8 changed files with 225 additions and 31 deletions
+7 -2
View File
@@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Rounding import (
OutputPlace, OutputPlace,
round_at, round_at,
) )
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build from B09_Estimation.B09_Estimation_UnitPrice import (
HOURLY_WAGE_SUFFIX,
UnitPriceBuild,
cached_build,
)
_ZERO = Decimal(0) _ZERO = Decimal(0)
@@ -55,7 +59,8 @@ def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for code in sorted(build.book.titles): for code in sorted(build.book.titles):
title = build.book.titles[code] title = build.book.titles[code]
if title.kind is not kind: # 조종원 시간당(`…#시간`)은 기초단가가 아니라 일당에서 **셈한** 값 — 노무비목록표엔 안 실음.
if title.kind is not kind or code.endswith(HOURLY_WAGE_SUFFIX):
continue continue
try: try:
price: Decimal | None = title.adopted_price() price: Decimal | None = title.adopted_price()
@@ -31,8 +31,8 @@ from decimal import Decimal
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_ALLOWANCE_NOTICE, OPERATOR_ALLOWANCE_NOTICE,
hourly_operator_wage,
) )
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -169,7 +169,8 @@ def base_reference_data(
"day_wage_krw": _money(Decimal(str(wage))), "day_wage_krw": _money(Decimal(str(wage))),
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③). # 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다. # 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
"hourly_krw": _money(Decimal(str(wage)) / Decimal(8) * OPERATOR_ALLOWANCE_FACTOR), # 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1).
"hourly_krw": _money(hourly_operator_wage(Decimal(str(wage)))),
"formula": "일당 ÷ 8시간 × 16/12 × 25/20", "formula": "일당 ÷ 8시간 × 16/12 × 25/20",
} }
) )
+33 -9
View File
@@ -23,8 +23,9 @@ from __future__ import annotations
import json import json
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import ROUND_FLOOR, Decimal
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_Guards import ( from B09_Estimation.B09_Estimation_Guards import (
@@ -73,6 +74,30 @@ OPERATOR_HOURS_PER_DAY = 8
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호). #: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20)) OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20))
#: 조종원 **시간당 노임 식** — 위 8시간·계수를 **식 문자열 한 줄**로(STmate 설정 `RXNM_`).
#: ⚠⚠ **왼쪽부터 차례로** 평가하고 **원 미만 절사**(명세 7장 · STmate 18번 §2.1).
#: 계수로 미리 접으면 `267,360 × 0.20833…` = 55,699.99… → 절사 55,699 로 **1원 틀림**.
#: 차례로 하면 33,420 → 534,720 → 44,560 → 1,114,000 → **55,700**(6건×3직종 18/18 일치).
#: ⚠ 위 `OPERATOR_ALLOWANCE_FACTOR` 는 근거 문구·검사용으로만 남김 — 금액에 곱하지 말 것.
OPERATOR_WAGE_FORMULA = "1/8*16/12*25/20"
def hourly_operator_wage(
daily_wage: Decimal, formula: str = OPERATOR_WAGE_FORMULA, *, digits: int | None = 0
) -> Decimal:
"""일 노임 → 시간당 노임. 식을 **좌→우 순차**로 풀고 `digits` 자리 아래 절사(`None` 이면 안 자름).
⚠ 자르는 자리는 **실무 설정**임 — 실무 여섯 건 중 다섯이 원 미만 절사(55,700),
2025 울진 소광 한 건이 0.1원 미만 절사(57,077.2). 기본은 다수인 0 자리.
"""
value = Decimal(daily_wage)
for op, number in re.findall(r"([*/]?)\s*(\d+(?:\.\d+)?)", formula):
value = value / Decimal(number) if op == "/" else value * Decimal(number)
if digits is None:
return value
return value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_FLOOR)
#: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다. #: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다.
OPERATOR_ALLOWANCE_NOTICE = ( OPERATOR_ALLOWANCE_NOTICE = (
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — " "조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
@@ -227,8 +252,6 @@ def hourly_machine_cost(
fuel_liters_per_hour: Decimal | None = None, fuel_liters_per_hour: Decimal | None = None,
fuel_price_per_liter: Decimal | None = None, fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None, operator_daily_wage: Decimal | None = None,
operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY,
operator_allowance_factor: Decimal = OPERATOR_ALLOWANCE_FACTOR,
efficiency_factor: Decimal | None = None, efficiency_factor: Decimal | None = None,
) -> HourlyMachineCost: ) -> HourlyMachineCost:
"""시간당 사용료 한 시간분. """시간당 사용료 한 시간분.
@@ -257,15 +280,16 @@ def hourly_machine_cost(
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다. # TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음") gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else: else:
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 나눗수를 줄이는 것과 다르다. # 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 식 문자열을 좌→우로 풂(명세 7장).
labor = (operator_daily_wage / Decimal(operator_hours_per_day)) * operator_allowance_factor exact = hourly_operator_wage(operator_daily_wage, digits=None)
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다. # ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다(자르기 전 값으로 봄).
check_operator_hours_basis( check_operator_hours_basis(
labor_per_hour=labor, labor_per_hour=exact,
daily_wage=operator_daily_wage, daily_wage=operator_daily_wage,
hours_per_day=operator_hours_per_day, hours_per_day=OPERATOR_HOURS_PER_DAY,
allowance_factor=operator_allowance_factor, allowance_factor=OPERATOR_ALLOWANCE_FACTOR,
) )
labor = exact.to_integral_value(rounding=ROUND_FLOOR)
return HourlyMachineCost( return HourlyMachineCost(
machine=machine, machine=machine,
@@ -25,8 +25,7 @@ from functools import lru_cache
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR, hourly_operator_wage,
OPERATOR_HOURS_PER_DAY,
load_machine_catalog, load_machine_catalog,
) )
from B09_Estimation.B09_Estimation_PriceBook import PriceKind from B09_Estimation.B09_Estimation_PriceBook import PriceKind
@@ -150,10 +149,9 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
), ),
"operator_code": occupation, "operator_code": occupation,
"operator_daily_wage": _money(wage), "operator_daily_wage": _money(wage),
# 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림.
"operator_krw_per_hour": _money( "operator_krw_per_hour": _money(
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR hourly_operator_wage(wage) if wage is not None else None
if wage is not None
else None
), ),
# ③ 시간당 사용료 — 조립된 값(이 장의 결론) # ③ 시간당 사용료 — 조립된 값(이 장의 결론)
"material_krw": _money(money.material), "material_krw": _money(money.material),
+19 -12
View File
@@ -27,7 +27,8 @@ from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR, OPERATOR_WAGE_FORMULA,
hourly_operator_wage,
load_machine_catalog, load_machine_catalog,
) )
from B09_Estimation.B09_Estimation_MachineProductivity import ( from B09_Estimation.B09_Estimation_MachineProductivity import (
@@ -68,6 +69,8 @@ from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_Transport import parse_distance_km from B09_Estimation.B09_Estimation_Transport import parse_distance_km
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: 조종원 **시간당 노임** 제목 꼬리 — 일 노임 제목(`L…`)과 갈라 둠. 노무비목록표엔 안 실림(일당만).
HOURLY_WAGE_SUFFIX = "#시간"
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0) _ZERO = Decimal(0)
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
@@ -385,22 +388,26 @@ def _add_machine_layers(
wage_code = record.operator_occupation_code wage_code = record.operator_occupation_code
if wage_code and wage_code in wages and record.operator_person_days is not None: if wage_code and wage_code in wages and record.operator_person_days is not None:
# ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다. # ㉣ 나눗수는 8시간 + **제수당·상여금·퇴직급여충당금 계수**(16/12×25/20) — 공표 노임이
# 여기에 **제수당·상여금·퇴직급여충당금 계수**(1.667배)를 곱한다. 공표 노임이 # 기본급여액뿐이라 별도 계상하는 몫(`MachineCost` 상수 주석에 근거).
# 기본급여액뿐이라 별도 계상해야 하는 몫이다(`MachineCost` 상수 주석에 근거). # ⚠⚠ 계수를 **수량에 접지 않음** — `일당 × 0.20833…` 은 55,699.99… 로 1원 틀림.
per_hour_person = (record.operator_person_days / Decimal(8)) * OPERATOR_ALLOWANCE_FACTOR # 시간당 노임을 식 좌→우 + 원 미만 절사로 **따로 세운 제목**(`#시간`)을 1시간분 인수로 부름
if wage_code not in book.titles: # (STmate 중기사용료 호표 「운전원 1 × 55,700」 모양, 명세 7장).
per_hour_person = record.operator_person_days
hourly_wage_code = f"{wage_code}{HOURLY_WAGE_SUFFIX}"
if hourly_wage_code not in book.titles:
book.add_title( book.add_title(
PriceTitle( PriceTitle(
code=wage_code, code=hourly_wage_code,
kind=PriceKind.LABOR, kind=PriceKind.LABOR,
name="조종원", name="조종원(시간당)",
unit="", unit="hr",
slots=_slots(wages[wage_code]), slots=_slots(hourly_operator_wage(wages[wage_code])),
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다. # ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
reliability=load_labor_reliability().get(wage_code, ""), reliability=load_labor_reliability().get(wage_code, ""),
) )
) )
wage_code = hourly_wage_code
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다. # 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합" combined_code = f"{hourly_code}#조합"
if combined_code in book.titles: if combined_code in book.titles:
@@ -409,7 +416,7 @@ def _add_machine_layers(
combined_code, combined_code,
wage_code, wage_code,
per_hour_person, per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
) )
) )
book.add_detail( book.add_detail(
@@ -417,7 +424,7 @@ def _add_machine_layers(
hourly_code, hourly_code,
wage_code, wage_code,
per_hour_person, per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
) )
) )
else: else:
@@ -0,0 +1,89 @@
"""STmate 골든셋 재현 — 실무 내역 원본(평문 XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장).
기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX).
브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**.
② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ 이 판
④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 (다음 단계)
③ 단가산출 Q 식 `단가산출서` 시트 — 시간당 단가 ÷ Q (층 차례 뒤)
① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막)
⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님).
⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀.
"""
from __future__ import annotations
import pathlib
import warnings
from decimal import Decimal
from functools import lru_cache
import pytest
from B09_Estimation.B09_Estimation_MachineCost import hourly_operator_wage
ROOT = pathlib.Path(__file__).resolve().parents[2]
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
@lru_cache(maxsize=1)
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
openpyxl = pytest.importorskip("openpyxl")
wanted = ("환율및기초자료",)
found = []
for path in sorted(PRACTICE.rglob("*.xlsx")):
if path.name.startswith("~$"):
continue
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception: # 깨진 사본 — 기준점이 아님
continue
sheets = {
name: list(book[name].iter_rows(max_col=12, values_only=True))
for name in wanted
if name in book.sheetnames
}
book.close()
if sheets:
found.append((str(path.relative_to(PRACTICE)), sheets))
return tuple(found)
def _decimal_places(value: Decimal) -> int:
return max(0, -value.normalize().as_tuple().exponent)
def _wage_rows() -> list[tuple[str, str, Decimal, str, Decimal]]:
rows = []
for name, sheets in _workbooks():
for row in sheets.get("환율및기초자료", []):
if len(row) < 7 or not isinstance(row[3], (int, float)) or not isinstance(row[4], str):
continue
if row[2] and isinstance(row[6], (int, float)):
formula = row[4].replace("*", "", 1).replace("=", "").strip()
rows.append(
(name, str(row[2]), Decimal(str(row[3])), formula, Decimal(str(row[6])))
)
return rows
def test_노임_시간당_환산_실무_원본_전수_재현() -> None:
"""② 일당 × `1/8*16/12*25/20` 좌→우 순차 + 절사 — 실무 원본의 시간당 칸과 **전부** 같음.
절사 자리는 원본이 보인 대로(다섯 건 원 · 2025 울진 소광 0.1원) — 설정값이라 칸에서 읽음.
"""
rows = _wage_rows()
if not rows:
pytest.skip("실무 원본 XLSX 가 없음")
assert len(rows) >= 18 # 6건 × 운전사 3직종
misses = [
(name, job, daily, expected, got)
for name, job, daily, formula, expected in rows
if (got := hourly_operator_wage(daily, formula, digits=_decimal_places(expected)))
!= expected
]
assert not misses, misses
+69
View File
@@ -0,0 +1,69 @@
"""조종원 시간당 노임 — 식 좌→우 순차 + 원 미만 절사 (2026-09-13, PLAN 6장 · 명세 7장).
기준점 — STmate 분석 18번 §2.1: 6건 × 3직종 18/18 이 좌→우일 때만 일치(계수 선계산 14/18).
건설기계운전사 267,360 → 55,700 · 화물차운전사 226,709 → 47,231 ·
일반기계운전사 161,142 → 33,571
"""
from __future__ import annotations
from decimal import Decimal
from functools import lru_cache
import pytest
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_WAGE_FORMULA,
hourly_operator_wage,
)
from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, build_unit_prices
@pytest.mark.parametrize(
("daily", "hourly"),
[(267360, 55700), (226709, 47231), (161142, 33571)],
)
def test_좌에서_우로_차례로_풀고_원_미만_절사(daily: int, hourly: int) -> None:
assert OPERATOR_WAGE_FORMULA == "1/8*16/12*25/20"
assert hourly_operator_wage(Decimal(daily)) == Decimal(hourly)
def test_계수를_미리_접으면_1원_틀린다() -> None:
"""회귀 막이 — 접은 계수 곱은 55,699.99… 라 절사하면 55,699(STmate 55,700)."""
folded = (Decimal(267360) / 8 * OPERATOR_ALLOWANCE_FACTOR).to_integral_value(
rounding="ROUND_FLOOR"
)
assert folded == Decimal(55699) # 종전 코드 — 계수 1.666…6 이 끝자리에서 잘려 55,699.99…
assert hourly_operator_wage(Decimal(267360), digits=None) == Decimal(55700)
@lru_cache(maxsize=1)
def _build():
return build_unit_prices()
def test_중기_호표의_운전원은_시간당_노임_제목을_1시간분_부른다() -> None:
book = _build().book
hourly = [code for code in book.titles if code.endswith(HOURLY_WAGE_SUFFIX)]
assert hourly, "조종원 시간당 제목이 한 벌도 안 섬"
for code in hourly:
daily = (
book.titles[code.removesuffix(HOURLY_WAGE_SUFFIX)].adopted_price()
if code.removesuffix(HOURLY_WAGE_SUFFIX) in book.titles
else None
)
price = book.titles[code].adopted_price()
assert price == price.to_integral_value() # 원 미만 절사
if daily is not None:
assert price == hourly_operator_wage(daily)
# X 층 운전원 줄은 계수 접은 수량이 아니라 시간당 제목 × 인수.
operator_rows = [
detail
for details in book.details.values()
for detail in details
if detail.ref_code.endswith(HOURLY_WAGE_SUFFIX)
]
assert operator_rows and all(
d.quantity == d.quantity.to_integral_value() for d in operator_rows
)
+2 -1
View File
@@ -46,7 +46,8 @@ def test_암절취는_암파쇄와_집토의_합으로_선다():
assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf)
whole = book.resolve("B-FP-09-04#연암").total whole = book.resolve("B-FP-09-04#연암").total
parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total
assert whole == parts > 0 # 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합.
assert parts > 0 and abs(whole - parts) < Decimal("1e-15")
assert "B-FP-09-04#평균" in book.titles # [주]① 평균 assert "B-FP-09-04#평균" in book.titles # [주]① 평균