Files
Aislo/resources/tester/test_b09_golden_stmate.py
T
eomsangdonandClaude Opus 5 3ce997fa69 feat(b09): 표 화면 넷 — 중기(목록표·중기사용료) · 자재단가대비표 · 집계표 넷 · 목록표 일곱
- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음
  (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임
- 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144)
- 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함
- 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에
- 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot)
- 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감
- 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임)
- 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:56:02 +09:00

442 lines
21 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 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅
① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅
⚠ 실무 원본은 **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=13, 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)
def _basis_blocks() -> list[tuple[str, str, tuple, list[tuple]]]:
"""`단가산출근거` 시트의 호표 — `(원본, 호표, 머리 줄, Q 줄들)`. 머리 = 합계·노무·재료·경비.
`소계(제외금액)`(틀 `(-=)`) 위 줄은 머리에 안 들어감 — Q 를 끌어내려고 보인 인력 품 따위.
"""
found = []
for name, sheets in _workbooks():
head: tuple | None = None
rows: list[tuple] = []
kept = 0 # 마지막 소계까지 든 줄 수
for row in [*sheets.get("단가산출근거", []), ("총계", None)]:
label = str(row[1] or "").replace(" ", "")
first = str(row[0] or "").replace(" ", "")
if (label.startswith("제") and label.endswith("호표")) or first.startswith("총"):
if head is not None and rows:
found.append((name, str(head[1]).replace(" ", ""), head, rows))
head, rows, kept = (row if label.endswith("호표") else None), [], 0
continue
if label.startswith("소계") or label.startswith("계("):
template = str(row[6])
if "(-==)" in template: # 계(제외금액) — 그때까지 든 줄 전부
rows, kept = [], 0
elif "(-=)" in template: # 소계(제외금액) — 마지막 소계 뒤 줄
rows = rows[:kept]
else:
kept = len(rows)
continue
if (
head is not None
and first.startswith("계") # 「계」 · 「계(경비로적용)」
and _num(row[2]) is not None
and _num(row[7]) is None # 「계약단가」·「계 x 낙찰율」 은 율 칸이 참
):
# 머리가 낙찰률을 곱한 계약단가인 원본 — 절사 규칙 대조는 그 앞 「계」 줄로.
head = (head[0], head[1], *row[2:])
continue
if (
head is not None
and len(row) > 8
and _num(row[7]) is not None
and _num(row[8]) is not None
):
rows.append(row)
return found
def _assemble_basis(rows: list[tuple]) -> Money3:
"""원본 Q 줄(QTY · 성분 단가 J·K·L)을 **우리 D 층**에 올려 풂 — 줄 0.1원 · 머리 원 미만(엔진 규칙).
성분 단가 칸이 둘 이상 찬 줄은 다른 호표 참조(「산근 N호표」) — 성분마다 잎 하나씩.
"""
kinds = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE)
book = PriceBook()
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
for index, row in enumerate(rows):
quantity, price, shown = _num(row[7]), _num(row[8]), _num(row[2]) or Decimal(0)
if abs(quantity * price / 100 - shown) < abs(quantity * price - shown):
quantity /= 100 # 공구손료 「노무비 × 2 %」 — 칸 QTY 가 백분율 수인 줄
parts = [(k, _num(p)) for k, p in zip(kinds, (*row[9:12], None, None, None))]
parts = [(k, p) for k, p in parts if p]
if not parts: # 성분 단가 칸이 빈 원본 — 줄 금액이 든 성분 칸으로 가름
kind = kinds[next((i for i in range(3) if _num(row[3 + i])), 2)]
parts = [(kind, price)]
for kind, part in parts:
code = f"R{index}{kind.name}"
book.add_title(PriceTitle(code, kind, str(row[1]), slots=_slots(part)))
book.add_detail(PriceDetail("D", code, quantity))
return book.resolve("D")
def test_단가산출_Q_식_실무_원본_호표_전수_재현() -> None:
"""③ 줄 = 중기 성분 × 1/Q(Q 소수 2자리 확정) → 0.1원 미만 절사 · 머리 성분 = 원 미만 절사.
회귀 기준 — 봉화 2024 제2호표 굴삭기 0.2㎥ Q 15.71: 노무 55,700 ÷ Q = **3,545.5** → 머리 3,545(명세 7장).
"""
blocks = _basis_blocks()
if not blocks:
pytest.skip("실무 원본 XLSX 가 없음")
misses = []
for name, title, head, rows in blocks:
got = _assemble_basis(rows)
want = (_num(head[2]), _num(head[3]), _num(head[4]), _num(head[5]))
if not want[1] and not want[2] and want[0] == want[3]:
# 경비로 넘기는 호표(운반·소운반 따위) — 성분 절사 합을 경비 한 칸에 모음.
got = Money3(expense=got.total)
if (got.total, got.labor, got.material, got.expense) != want:
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
assert len(blocks) >= 200, len(blocks) # 6건 208 호표
assert not misses, (len(misses), misses[:5])
second = next(b for b in blocks if "현동" in b[0] and b[1] == "제2호표")
assert Decimal("3545.5") in [_num(r[3]) for r in second[3]]
assert _assemble_basis(second[3]).labor == Decimal(3545)
def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None:
"""③ 엔진 — 18번 §2.2 예: q 0.2 · f 1/1.175 → 0.85 · K 0.7 · Cm 15 · E 0.55 → Q **15.71**."""
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
hourly_output,
machine_hours_per_unit,
)
factors = CycleFactors(
"T", "T", "0201-0020", "굴삭기", Decimal("0.2"), Decimal("0.7"),
Decimal(1) / Decimal("1.175"), Decimal("0.55"), Decimal(15),
) # fmt: skip
assert hourly_output(factors) == Decimal("15.71") # 원값 15.708…
book = PriceBook()
book.add_title(PriceTitle("X", PriceKind.MACHINE_BASE, "중기", slots=_slots(Decimal(55700))))
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors)))
# 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림.
assert book.resolve("D").expense == Decimal(3545)
_COMPONENT_KINDS = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE)
def _unit_price_blocks() -> list[tuple[str, str, tuple, list[tuple]]]:
"""`일위대가표` 시트의 호표 — `(원본, 호표, 합계 줄, 구성 줄들)`.
칸: 명칭 · 규격 · 수량 · 단위 · 합계(단가·금액) · 노무 · 재료 · 경비. 끝 줄은 「합계」·「계」·
「합계(경비로적용)」 — 뒤따르는 「계약단가」(낙찰률 곱)는 절사 규칙 대조 밖.
"""
found = []
for name, sheets in _workbooks():
rows: list[tuple] | None = None
title = ""
for row in sheets.get("일위대가표", []):
head = str(row[0] or "").replace(" ", "")
if head.startswith("제") and head.endswith("호표"):
rows, title = [], head
elif rows is None:
continue
elif head in ("합계", "계") or head.startswith("합계("):
found.append((name, title, row, rows))
rows = None
elif _num(row[2]) is not None and row[3]:
rows.append(row)
return found
def _assemble_unit_price(rows: list[tuple]) -> Money3:
"""원본 구성 줄(수량 · 성분 단가)을 **우리 B 층**에 올려 풂 — 줄 0.1원 · 성분 소계 원 미만(엔진)."""
book = PriceBook()
book.add_title(PriceTitle("B", PriceKind.UNIT_PRICE, "일위대가"))
for index, row in enumerate(rows):
quantity = _num(row[2])
if str(row[3]).strip() == "%": # 「노무비의 3 %」 — 단가 칸이 밑수, 수량 칸이 백분율 수
quantity /= 100
for kind, part in zip(_COMPONENT_KINDS, (_num(row[6]), _num(row[8]), _num(row[10]))):
if part:
code = f"R{index}{kind.name}"
book.add_title(PriceTitle(code, kind, str(row[0]), slots=_slots(part)))
book.add_detail(PriceDetail("B", code, quantity))
return book.resolve("B") if book.details else Money3()
def test_일위대가_실무_원본_호표_성분_소계_절사() -> None:
"""① 줄 = 수량 × 성분 단가 → 0.1원 미만 절사 · 성분 소계 원 미만 절사(명세 7장).
⚠ **100%를 좇지 않음**(브레인 판정) — 17번 원문이 「345건 중 94.2%, 나머지는 호표 머리·부분계
서식이 달라 자동 구획이 어긋난 것」이라 적음. 안 맞는 호표는 **서식 탓·반올림 탓**을 갈라 둠.
"""
blocks = _unit_price_blocks()
if not blocks:
pytest.skip("실무 원본 XLSX 가 없음")
misses, components, matched = [], 0, 0
for name, title, total, rows in blocks:
got = _assemble_unit_price(rows)
want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11]))
if not want[1] and not want[2] and want[0] == want[3]:
got = Money3(expense=got.total) # 합계(경비로적용)
pairs = list(zip(want[1:], (got.labor, got.material, got.expense)))
components += len(pairs)
matched += sum(w == g for w, g in pairs)
if (got.total, got.labor, got.material, got.expense) != want:
misses.append(
(name, title, want, (got.total, got.labor, got.material, got.expense), rows)
)
assert len(blocks) >= 150, len(blocks) # 6건 170 호표 · 성분 소계 510
# 성분 소계 477/510 = 93.5%(17번 원문 94.2% 언저리). 안 맞는 19 호표는 둘로 갈림 —
# 반올림 탓 17: 영월 2024 한 건이 일위대가표를 **절사가 아닌 반올림 자리**로 뽑음(같은 원본의
# 중기사용료·단가산출근거는 절사로 전수 일치 — 그 원본의 단수 설정, 엔진 규칙 아님)
# 서식 탓 2: 영덕 2025 「켜쌓기적용 90 %」 줄이 앞 줄 합을 밑수로 받아 **앞 줄을 대신함**
assert matched >= components * 0.93, (matched, components)
formatted = [m for m in misses if "영월" not in m[0]]
assert len(formatted) <= 2, [m[:4] for m in formatted]
assert all(any(str(r[3]).strip() == "%" for r in m[4]) for m in formatted)
# 봉화 2024 제1호표 깬잡석찰쌓기 — 노무 20,714.8 + 6,621.8 + 16,710 = 44,046 · 합계 58,889.
first = next(b for b in blocks if "현동" in b[0] and b[1] == "제1호표")
assert _assemble_unit_price(first[3]).total == Decimal(58889)
def _bill_rows() -> list[tuple[str, tuple]]:
"""`설계내역서` 말단 줄 — 칸: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비(단가·금액)."""
return [
(name, row)
for name, sheets in _workbooks()
for row in sheets.get("설계내역서", [])
if _num(row[3]) is not None and row[4] and any(_num(row[i]) for i in (7, 9, 11))
]
def test_내역_줄_금액은_성분마다_절사한_합() -> None:
"""① 내역 줄 — 성분마다 `절사(수량 × 성분 단가)` · 합계 = 셋의 합(16번 1,515건 · 착공 178건 100%)."""
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
rows = _bill_rows()
if not rows:
pytest.skip("실무 원본 XLSX 가 없음")
misses = []
for name, row in rows:
unit = Money3(material=_num(row[9]), labor=_num(row[7]), expense=_num(row[11]))
quantity = _num(row[3])
if str(row[4]).strip() == "%": # 조달수수료 「재료비 × 0.54 %」 — 단가 칸이 밑수
quantity /= 100
line = bill_line(unit, quantity)
got = (line.total, line.labor, line.material, line.expense)
want = (_num(row[6]), _num(row[8]), _num(row[10]), _num(row[12]))
if got != want:
misses.append((name, row[1], want, got))
assert len(rows) >= 300, len(rows)
assert not misses, (len(misses), misses[:5])
def test_집계표_금액은_반올림_중기는_성분마다_반올림한_합() -> None:
"""집계표 넷 — 재료·노무·경비는 `반올림(수량 × 단가)`, 중기는 **성분마다 반올림한 뒤 합**(단가 열 없음).
실측 — 자원 셋 279/279 · 중기 131/144(합계 한 번에 반올림 107/144). 중기 짝은 목록표 명칭·규격으로 찾음
(같은 명칭·규격이 갈래로 둘인 기종은 짝이 흔들림 — 규칙 탓 아님).
"""
from B09_Estimation.B09_Estimation_Lists import machine_summary_amounts
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
resource, machine, machine_hits = 0, 0, 0
for _name, sheets in _workbooks():
for sheet in ("재료비수량금액집계표", "노무비수량금액집계표", "경비수량금액집계표"):
for row in sheets.get(sheet, []):
quantity, price, amount = _num(row[3]), _num(row[5]), _num(row[6])
if quantity is None or price is None or amount is None:
continue
resource += 1
assert round_at(quantity * price, OutputPlace.RESOURCE_SUMMARY) == amount, row
units = {
(row[1], row[2]): Money3(_num(row[6]), _num(row[5]), _num(row[7]))
for row in sheets.get("중기목록표", [])
if _num(row[4]) is not None
}
for row in sheets.get("중기시간금액집계표", []):
quantity = _num(row[3])
if quantity is None or (row[1], row[2]) not in units:
continue
machine += 1
parts = machine_summary_amounts(units[(row[1], row[2])], quantity)
got = (sum(parts.values()), parts["labor"], parts["material"], parts["expense"])
machine_hits += got == tuple(_num(v) or Decimal(0) for v in row[5:9])
if not resource:
pytest.skip("실무 원본 XLSX 가 없음")
assert resource >= 250 and machine >= 100, (resource, machine)
assert machine_hits >= machine * 0.9, (machine_hits, machine)