diff --git a/resources/tester/test_b09_golden_cost_sheet.py b/resources/tester/test_b09_golden_cost_sheet.py new file mode 100644 index 00000000..529e25a3 --- /dev/null +++ b/resources/tester/test_b09_golden_cost_sheet.py @@ -0,0 +1,369 @@ +"""STmate 골든셋 ⑤ — 공사원가계산서를 **우리 엔진**으로 되풀어 대조(PLAN 6장·12장 · 명세 8장). + +짝 파일 `test_b09_golden_stmate.py` 의 다섯째 벌임 — 그 파일이 700줄에 닿아 갈라 둠. +실무 원본 읽기는 그 파일의 `_workbooks` 를 그대로 씀(한 번만 읽어 나눠 쓰는 자리). + +재는 것 — 제비율(법정경비) · 간접노무비 · 일반관리비 · 이윤 · 부가세 · 도급공사비 · 총공사비. + 요율은 **원본 비고에서 뽑아** 그 원본 전용 요율 판을 지어 넣음 — + 「요율이 달라서 틀림」이 안 생김. + 남는 차이는 **밑수 고르기 · 원 단위 버림 · 사슬**, 곧 우리 엔진 몫임. + +⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). +⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 입력**으로만 씀. +⚠ 이 벌은 **재기만 함** — 값을 맞추려고 엔진을 고치지 않음(브레인 지시 2026-09-14). +""" + +from __future__ import annotations + +import json +import pathlib +import re +from dataclasses import replace +from decimal import Decimal + +import pytest +from test_b09_golden_stmate import _num, _workbooks + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost + +#: 원본 줄 이름(공백 지운 것) → 엔진 줄 키. 「소계」 셋은 나온 차례로 재료비·노무비·경비. +_COST_KEYS: dict[str, str] = { + "직접재료비": "direct_material", + "간접재료비": "indirect_material", + "작업설,부산물등(△)": "byproduct", + "작업설.부산물등(△)": "byproduct", + "직접노무비": "direct_labor", + "간접노무비": "indirect_labor_cost", + "산출경비": "direct_expense", + "산재보험료": "industrial_accident_insurance", + "고용보험료": "employment_insurance", + "건강보험료": "health_insurance", + "노인장기요양보험료": "long_term_care_insurance", + "연금보험료": "national_pension", + "산업안전보건관리비": "safety_management_cost", + "기타경비": "other_expense", + "환경보전비": "environment_preservation", + "건설기계대여금지급보증금액": "equipment_payment_guarantee", + "순공사원가": "net_construction_cost", + "일반관리비": "general_overhead", + "이윤": "profit", + "페기물처리": "waste_disposal", # 원본 표기(소광) 그대로 + "폐기물처리비": "waste_disposal", + "총원가": "total_cost", + "부가가치세": "vat", + "도급공사비": "contract_amount", + "관급자재대": "owner_supplied_material_total", + "관급자재대(도급자설치)": "owner_supplied_material_total", + "총공사비": "grand_total", +} +#: 원본 비고에 요율이 적히는 법정경비 줄 — 이 줄만 엔진과 견줌. +_STATUTORY_KEYS = ( + "industrial_accident_insurance", + "employment_insurance", + "health_insurance", + "long_term_care_insurance", + "national_pension", + "safety_management_cost", + "other_expense", + "environment_preservation", + "equipment_payment_guarantee", +) +_RE_PERCENT = re.compile(r"[x×]\s*([\d.]+)\s*%") +_RE_FLAT_ADD = re.compile(r"%\s*\+\s*([\d,]+)") +_RE_OWNER_NOTE = re.compile(r"원자재대[::]\s*([\d,]+).*?조달수수료[::]\s*([\d,]+)", re.S) +_RE_EXEMPT = re.compile(r"면세품목[::]\D*([\d,]+)") +_RE_SAFETY_AB = re.compile(r"A[))][^=]*=\s*([\d,]+).*?B[))][^=]*=\s*([\d,]+)", re.S) +#: 합성 요율 판의 「어디에나 걸리는」 구간·기간 라벨 — 구간 갈래는 이 벌이 재는 것이 아님. +_WIDE_BRACKET = "lt_1000_billion" +_WIDE_DURATION = "lte_100000_days" +#: 연금 요율을 원본이 보인 값 그대로 쓰려고 두는 표식 해(연도 갈래는 이 벌 밖). +_SHEET_YEAR = 2000 + + +def _won(text: object) -> Decimal: + return Decimal(str(text).replace(",", "")) + + +def _cost_rows(rows: list[tuple]) -> tuple[dict[str, tuple], list[tuple[str, Decimal]]]: + """`공사원가계산서` 시트 → `({키: (금액, 요율, 기초액, 비고)}, 이름 못 고른 줄들)`. + + 금액 칸은 「금 액」 머리로 찾고, 당초/변경/증감으로 갈린 원본(영덕 변경설계)은 **변경** 칸을 씀. + """ + head = next(r for r in rows if any("금" in str(c or "") and "액" in str(c or "") for c in r)) + amount_col = next( + i for i, c in enumerate(head) if "금" in str(c or "") and "액" in str(c or "") + ) + note_col = next( + (i for i, c in enumerate(head) if "비" in str(c or "") and "고" in str(c or "")), None + ) + body = rows[rows.index(head) + 1 :] + for index, cell in enumerate(body[0] if body else ()): + if "변" in str(cell or "") and "경" in str(cell or ""): + amount_col = index + got: dict[str, tuple] = {} + unknown: list[tuple[str, Decimal]] = [] + subtotals = 0 + for row in body: + label = "" + for cell in row[:amount_col]: + if isinstance(cell, str) and cell.strip(): + label = cell.replace(" ", "").replace(" ", "") + amount = _num(row[amount_col]) if amount_col < len(row) else None + if amount is None or not label: + continue + if label == "소계": + subtotals += 1 + key = ("material_cost", "labor_cost", "expense")[subtotals - 1] + else: + key = _COST_KEYS.get(label) + if key is None: + unknown.append((label, amount)) + continue + note = ( + " ".join(str(c) for c in row[note_col:] if isinstance(c, str)) + if note_col is not None + else "" + ) + percent = _RE_PERCENT.search(note) + flat = _RE_FLAT_ADD.search(note) + got[key] = ( + amount, + Decimal(percent.group(1)) if percent else None, + _won(flat.group(1)) if flat else Decimal(0), + note, + ) + return got, unknown + + +def _cost_sheets() -> list[tuple[str, dict[str, tuple], list[tuple[str, Decimal]]]]: + return [ + (name, *_cost_rows(sheets["공사원가계산서"])) + for name, sheets in _workbooks() + if "공사원가계산서" in sheets + ] + + +def _sheet_rate_dataset(got: dict[str, tuple]) -> dict: + """원본 비고에 적힌 요율만으로 **그 원본 전용 요율 판**을 지음. + + 비고가 빈 줄은 판에 안 들어가고, 그 줄은 엔진이 아예 안 세움(`available_items`). + """ + + def percent(key: str) -> Decimal | None: + row = got.get(key) + return row[1] if row else None + + variables: dict[str, object] = {} + if (rate := percent("indirect_labor_cost")) is not None: + variables["rate_indirect_labor"] = { + "brackets": [ + { + "direct_cost_bracket": _WIDE_BRACKET, + "duration_bracket": _WIDE_DURATION, + "work_type": "civil", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("industrial_accident_insurance")) is not None: + variables["rate_sanjae"] = {"rate_percent": float(rate)} + if (rate := percent("employment_insurance")) is not None: + variables["rate_goyong"] = { + "brackets": [ + { + "grade": "below_7", + "estimated_amount_bracket": "below_official_threshold", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("health_insurance")) is not None: + variables["rate_health"] = {"rate_percent": float(rate)} + if (rate := percent("long_term_care_insurance")) is not None: + variables["rate_care"] = {"rate_percent": float(rate)} + if (rate := percent("national_pension")) is not None: + variables["rate_pension"] = { + "annual_rates": [{"year": _SHEET_YEAR, "rate_percent": float(rate)}] + } + if (rate := percent("safety_management_cost")) is not None: + variables["rate_safety_pct"] = { + "brackets": [ + { + "target_amount_bracket": _WIDE_BRACKET, + "work_type": "civil", + "rate_percent": float(rate), + "base_amount_krw": float(got["safety_management_cost"][2]), + } + ] + } + if (rate := percent("other_expense")) is not None: + variables["rate_other_expense"] = { + "brackets": [ + { + "direct_cost_bracket": _WIDE_BRACKET, + "duration_bracket": _WIDE_DURATION, + "work_type": "civil", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("environment_preservation")) is not None: + variables["rate_environment"] = { + "all_work_types": [{"work_type": "civil_road", "rate_percent": float(rate)}] + } + if (rate := percent("equipment_payment_guarantee")) is not None: + variables["rate_equipment_payment_guarantee"] = { + "general_construction": [{"work_type": "civil_general", "rate_percent": float(rate)}], + "specialty_construction": [], + } + variables["rate_overhead"] = { + "civil_landscape_industrial": [ + { + "estimated_price_bracket": _WIDE_BRACKET, + "rate_percent": float(percent("general_overhead")), + } + ] + } + variables["rate_profit"] = { + "brackets": [ + {"estimated_price_bracket": _WIDE_BRACKET, "rate_percent": float(percent("profit"))} + ] + } + variables["rate_vat"] = {"rate_percent": float(percent("vat") or 0)} + return {"dataset_id": "sheet", "effective_date": "sheet", "variables": variables} + + +def _cost_input(got: dict[str, tuple], rate_path: pathlib.Path) -> CostInput: + """원본 칸을 엔진 입력으로 옮김 — 직접비 셋 · 관급 순자재대·수수료 · 부가세 방식 · 폐기물.""" + rate_path.write_text(json.dumps(_sheet_rate_dataset(got)), encoding="utf-8") + owner = _RE_OWNER_NOTE.search(got.get("owner_supplied_material_total", (0, 0, 0, ""))[3]) + gross, fee = (_won(owner.group(1)), _won(owner.group(2))) if owner else (Decimal(0), Decimal(0)) + exempt = _RE_EXEMPT.search(got.get("vat", (0, 0, 0, ""))[3]) + return CostInput( + direct_material_krw=got["direct_material"][0], + direct_labor_krw=got["direct_labor"][0], + direct_expense_krw=got["direct_expense"][0], + indirect_material_krw=got.get("indirect_material", (Decimal(0),))[0], + # ⚠ 관급 입력은 **순자재대** — 원본 비고의 원자재대는 조달수수료를 이미 머금음. + owner_supplied_material_krw=gross - fee, + procurement_fee_krw=fee, + pension_year=_SHEET_YEAR, + vat_mode="forest_coop_exempt" if exempt else "supply", + tax_exempt_material_krw=_won(exempt.group(1)) if exempt else Decimal(0), + waste_disposal_krw=got.get("waste_disposal", (Decimal(0),))[0], + waste_placement="after_profit", # 소광 원본 자리 — 이윤 뒤·총원가 안 + rate_file_path=str(rate_path), + ) + + +def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -> None: + """⑤ 제비율 줄 — 밑수 고르기 + 원 단위 버림이 원본과 **줄마다** 같음(실무 6건). + + 안 맞을 자리를 셋으로 갈라 둠 — + · 우리 엔진이 틀림 → `misses` 에 남아 실패로 드러남 + · 요율 데이터가 다름 → 요율을 원본에서 뽑아 쓰므로 **안 생김** + · 원본이 식을 안 보임 → 비고 빈 줄(건강·노인장기·연금·산업안전). 앞 차수 값을 그대로 + 물려 적은 자리라 되풀 수 없음 — 울진 신설·영덕·소광 각 4줄, 세어만 두고 대조 밖. + · 절사 자리가 다름 → 안전관리비 **B 줄** 1원(영월). 아래 `rounding` 에 따로 담음. + + ⭐ **고칠 자리 하나 — 안전관리비 B 의 절사 자리**(2026-09-14 실측, 브레인 보고 대상). + 우리 엔진 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 + STmate `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 + 여섯 원본 중 영월에서만 갈림(봉화·울진은 두 셈법이 같은 값). + **미채택 줄이라 총액엔 안 닿음** — + 영월도 A 를 채택함. 이 벌은 재기만 하므로 엔진은 그대로 두고 차를 세어만 둠. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + checked, blank, misses, rounding = 0, 0, [], [] + for index, (name, got, _unknown) in enumerate(sheets): + blank += sum(1 for k in _STATUTORY_KEYS if k in got and got[k][1] is None) + result = calculate_cost(_cost_input(got, tmp_path / f"rate{index}.json")) + for key in ("indirect_labor_cost", *_STATUTORY_KEYS): + if key not in got or got[key][1] is None: + continue + checked += 1 + have = result.amount(key) if result.has(key) else None + if have != got[key][0]: + misses.append((name, key, got[key][0], have)) + # 안전관리비 A·B 두 값도 원본 비고가 나란히 적음 — 작은 쪽 채택까지 확인. + pair = _RE_SAFETY_AB.search(got.get("safety_management_cost", (0, 0, 0, ""))[3]) + if pair: + checked += 2 + want = (_won(pair.group(1)), _won(pair.group(2))) + have_pair = ( + result.amount("safety_management_cost_a"), + result.amount("safety_management_cost_b"), + ) + if have_pair != want: + # 1원 이내는 절사 자리 차이(B 줄) — 그보다 크면 엔진이 틀린 것. + bucket = ( + rounding if max(abs(h - w) for w, h in zip(want, have_pair)) <= 1 else misses + ) + bucket.append((name, "safety_a_b", want, have_pair)) + assert result.amount("safety_management_cost") == min(have_pair) + assert len(sheets) >= 6, len(sheets) # 실무 6건 + # 54 = 요율이 보이는 줄 48(온전한 셋 ×10 + 빈칸 있는 셋 ×6) + 안전관리비 A·B 짝 6. + assert checked >= 54, checked + assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄 + assert not misses, misses + # 절사 자리 차 — 지금 아는 것은 영월 안전관리비 B 한 줄뿐. 늘면 여기서 드러남. + assert len(rounding) == 1 and "영월" in rounding[0][0], rounding + + +def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: + """⑤ 사슬 — 순공사원가·일반관리비·이윤·총원가·부가세·도급공사비·관급자재대·총공사비. + + 비고가 다 보이는 원본 셋(봉화·영월·울진 공통)만 사슬에 듦. 이윤은 두 걸음으로 가름 — + ① **자동절사**(총공사비 1,000원 미만 → 이윤에서 ÷1.1 반올림)로 맞는가 — 봉화 339 · 영월 632 + ② 안 맞으면 그 차는 **설계자 명시 이윤조정**(★법대로 8-10, 프로그램이 역산하지 않음). + 울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525). + 조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임. + 도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + full = [s for s in sheets if all(s[1][k][1] is not None for k in _STATUTORY_KEYS if k in s[1])] + assert len(full) >= 3, [s[0] for s in full] + chained, automatic, manual, misses = 0, [], [], [] + for index, (name, got, unknown) in enumerate(full): + data = _cost_input(got, tmp_path / f"chain{index}.json") + plain = calculate_cost(data) + gap = plain.amount("profit_before_adjustment") - got["profit"][0] + cut = calculate_cost(replace(data, cut_basis="grand_total", cut_unit_krw=1000)) + (automatic if cut.amount("profit") == got["profit"][0] else manual).append((name, gap)) + result = calculate_cost(replace(data, profit_adjustment_krw=gap)) + extra = sum((amount for _label, amount in unknown), Decimal(0)) + for key in ( + "material_cost", + "labor_cost", + "expense", + "net_construction_cost", + "general_overhead", + "profit", + "total_cost", + "vat", + "contract_amount", + "owner_supplied_material_total", + "grand_total", + ): + if key not in got: + continue + chained += 1 + have = result.totals.get(key) + if have is None: + have = result.amount(key) if result.has(key) else None + # 도급공사비 아래는 원본에만 있는 줄만큼 낮게 나옴 — 그 몫을 덜어 견줌. + want = got[key][0] - ( + extra if key in ("contract_amount", "grand_total") else Decimal(0) + ) + if have != want: + misses.append((name, key, want, have)) + assert chained >= 30, chained + assert not misses, misses + # 자동절사가 이윤을 그대로 맞추는 원본이 둘, 설계자 조정이 필요한 원본이 하나. + assert len(automatic) == 2, automatic + assert [g for _n, g in automatic] == [Decimal(339), Decimal(632)], automatic + assert [g for _n, g in manual] == [Decimal(905_980)], manual diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py index 17c69043..e8bb36c5 100644 --- a/resources/tester/test_b09_golden_stmate.py +++ b/resources/tester/test_b09_golden_stmate.py @@ -7,6 +7,7 @@ ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ ① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅ + ⑤ 공사원가계산서 제비율·이윤·부가세·총공사비 — 옆 `test_b09_golden_cost_sheet.py` ⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). ⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀. @@ -50,6 +51,7 @@ def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: "경비수량금액집계표", "중기시간금액집계표", "중기목록표", + "공사원가계산서", ) found = [] for path in sorted(PRACTICE.rglob("*.xlsx")):