"""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줄, 세어만 두고 대조 밖. · 절사 자리가 다름 → 아래 `rounding` 에 따로 담음. **지금은 비어 있어야 함**. ⭐ **안전관리비 B 의 절사 자리 — 2026-09-14 실증본 쪽으로 맞춤**(브레인 배정). 고침 전 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 고침 뒤 `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 = 원본 고시 제4조① 단서 문언도 이쪽임 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임. 여섯 원본 중 영월에서만 갈렸음(봉화·울진은 두 셈법이 같은 값). """ 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 1원이 마지막이었음). assert not rounding, rounding def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: """⑤ 사슬 — 순공사원가·일반관리비·이윤·총원가·부가세·도급공사비·관급자재대·총공사비. 비고가 다 보이는 원본 셋(봉화·영월·울진 공통)만 사슬에 듦. 이윤은 두 걸음으로 가름 — ① **자동절사**(총공사비 1,000원 미만 → 이윤에서 ÷1.1 반올림)로 맞는가 — 봉화 339 · 영월 632 ② 안 맞으면 그 차는 **설계자 명시 이윤조정**(★법대로 8-10, 프로그램이 역산하지 않음). 울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525). 조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임. 도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000. ⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 그래서 「안 켠 값」을 볼 때는 `cut_basis="none"` 으로 **손수 꺼서** 봄. 자동절사 쪽은 기본값 그대로 돌림. """ 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 = replace(_cost_input(got, tmp_path / f"chain{index}.json"), cut_basis="none") 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 def test_총공사비는_천원_미만_버림이_기본(tmp_path) -> None: """⑤ 총액 절사 — 실무 6건이 **여섯 다** `000` 으로 앉음(2026-09-14 기본으로 켬). 근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」. 끄면(`cut_basis="none"`) 여섯 다 끝자리가 남는 것으로 **절사가 실제로 도는지**를 가름. ⚠ 잔차 걸음도 ÷1.1 을 해야 앉음(2026-09-14 고침) — 그대로 빼면 울진 신설이 `654 → 999 → 900 → 910 → 909 …` 로 진동해 끝자리가 남았음. """ sheets = _cost_sheets() if not sheets: pytest.skip("실무 원본 XLSX 가 없음") landed, left_over = [], [] for index, (name, got, _unknown) in enumerate(sheets): data = _cost_input(got, tmp_path / f"cut{index}.json") # 기본값 그대로 result = calculate_cost(data) landed.append((name, result.totals["grand_total"] % 1000)) off = calculate_cost(replace(data, cut_basis="none")) left_over.append((name, off.totals["grand_total"] % 1000)) assert any("2025-82호" in note for note in result.notes), result.notes assert not [n for n in result.notes if "안 앉음" in n], result.notes assert [tail for _n, tail in landed] == [Decimal(0)] * len(sheets), landed assert all(tail for _n, tail in left_over), left_over