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
@@ -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)
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
assert whole == parts > 0
# 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합.
assert parts > 0 and abs(whole - parts) < Decimal("1e-15")
assert "B-FP-09-04#평균" in book.titles # [주]① 평균