"""STmate 골든셋 재현 — 실무 내역 원본(XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장). 기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX). 브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**. ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ ① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅ ⑤ 공사원가계산서 제비율·이윤·부가세·총공사비 — 옆 `test_b09_golden_cost_sheet.py` ⚠ 실무 원본은 **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)