Files
Aislo/resources/tester/test_b09_golden_stmate.py
T
eomsangdonandClaude Opus 5 50c3fb65f5 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
2026-09-13 23:29:16 +09:00

168 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from B09_Estimation.B09_Estimation_PriceBook import (
Money3,
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
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
def _num(value: object) -> Decimal | None:
return Decimal(str(value)) if isinstance(value, (int, float)) else None
def _machine_sheets() -> list[tuple[str, str, list[tuple], tuple]]:
"""`중기사용료` 시트의 호표 — `(원본, 호표 이름, 구성 줄들, 합계 줄)`."""
found = []
for name, sheets in _workbooks():
block: list[tuple] | None = None
title = ""
for row in sheets.get("중기사용료", []):
head = str(row[0] or "").replace(" ", "")
if head.startswith("제") and head.endswith("호표"):
block, title = [], ""
continue
if block is None:
continue
if head == "합계":
found.append((name, title, block, row))
block = None
elif row[2] is None and row[3] and not title:
title = f"{row[0]} {row[1] or ''}".strip()
elif _num(row[2]) is not None:
block.append(row)
return found
def _assemble(rows: list[tuple]) -> Money3 | None:
"""원본 호표 줄을 **우리 단가표 층**(S·L·M·잡품 비율 줄 → X)에 올려 풂. 모르는 줄이면 `None`."""
book = PriceBook()
book.add_title(PriceTitle("X", PriceKind.MACHINE_HOURLY, "호표"))
for index, row in enumerate(rows):
quantity, price, unit = _num(row[2]), _num(row[4]), str(row[3] or "").strip()
code = f"R{index}"
if unit == "%":
# 이름은 원본마다 다름(잡품·잡재료·잡유·잡재료비) — 「주연료의 %」 가산 행 모양.
book.add_detail(_misc_row("X", quantity))
continue
kind = {"천원": PriceKind.MACHINE_BASE, "인": PriceKind.LABOR}.get(unit, PriceKind.MATERIAL)
if price is None:
return None
book.add_title(PriceTitle(code, kind, str(row[0]), unit=unit, slots=_slots(price)))
book.add_detail(PriceDetail("X", code, quantity))
return book.resolve("X")
def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None:
"""④ 손료·운전원·주연료·**잡품(주연료비 × 율 가산 행)** — 줄 0.1원 · 성분 소계 원 미만 절사.
회귀 기준 — 봉화 2024 제2호표 굴삭기 0.7㎥: 23,128 + 55,700 + 18,015 = **96,843**(명세 7장).
"""
sheets = _machine_sheets()
if not sheets:
pytest.skip("실무 원본 XLSX 가 없음")
checked = 0
misses = []
for name, title, rows, total in sheets:
got = _assemble(rows)
if got is None:
continue
checked += 1
want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11]))
if (got.total, got.labor, got.material, got.expense) != want:
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
assert checked >= 50, checked
assert not misses, misses[:5]
bonghwa = next(t for t in sheets if "현동" in t[0] and "0.7" in t[1])
assert _assemble(bonghwa[2]).total == Decimal(96843)