Files
Aislo/resources/tester/test_b09_golden_cost_sheet.py
T
eomsangdonandClaude Opus 5 deac820044 test(b09): 골든셋을 원가계산서까지 넓힘 — 실무 6건 제비율 54자리·사슬 33자리 원 단위 재현
요율을 원본 「공사원가계산서」 비고에서 뽑아 그 원본 전용 판을 지어 엔진에 넣음 —
「요율 데이터가 달라서 틀림」을 없애고 남는 차이를 밑수·절사·사슬로 몰아 둠.

· 제비율 줄마다 54자리 전수 일치(안전관리비 A·B 와 작은 쪽 채택 포함)
· 사슬은 비고가 다 보이는 셋(봉화·영월·울진 공통) 순공사원가~총공사비 33자리 일치
· 이윤 — 자동절사가 맞춘 원본 둘(봉화 339 · 영월 632) · 설계자 명시 조정 하나(울진 905,980)
· 영월 도급공사비 차 762,000 = 원본에만 있는 국가지점번호판검증수수료 줄
· 고칠 자리 하나(배정 대기) — 안전관리비 B 절사 자리가 영월에서 1원 갈림(미채택 줄)
· 짝 파일이 700줄에 닿아 새 파일로 가름(원본 읽기는 공유)

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

370 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 골든셋 ⑤ — 공사원가계산서를 **우리 엔진**으로 되풀어 대조(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