- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(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
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
"""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)
|