# -*- coding: utf-8 -*- """조달청제비율 XLSX·국민연금법 XML → 요율_조달청제비율.json 대조 (읽기 전용 · 쓰기 기능 없음). 돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/build_요율.py --check (정본 JSON 을 덮는 기능 없음 — 값 자체는 조달청 고시·국민연금법 조문을 사람이 그대로 옮긴 값이라 자동 조립·저장은 하지 않고, 원문과 정본이 같은지 셀 단위로 대조만 한다.) 숫자만 대조 — 「왜」·「비고」 같은 설명 문구는 대상 아님. 브래킷 표는 라벨 문자열을 다시 만들지 않고, 원문의 행 순서와 JSON 줄 순서가 같다는 전제로 자리(위치)로 맞춰 비교한다 — 순서가 이미 눈으로 확인됨(이 파일 작성 시점 기준). """ from __future__ import annotations import argparse import json import re import sys import xml.etree.ElementTree as ET from pathlib import Path ROOT = Path(__file__).resolve().parents[3] RATE_JSON = ROOT / "resources/master_data/요율_조달청제비율.json" XLSX_PATH = ( ROOT / "resources/knowledge/original/행정규칙/토목공사원가계산 제비율 적용기준/첨부/2026.04.13_조달청_토목공사_제비율.xlsx" ) PENSION_XML = ROOT / "resources/knowledge/original/법률/국민연금법/현행_20260617.xml" SHEET = "26. 4. 13." def num(text) -> float | None: if text is None: return None if isinstance(text, (int, float)): return float(text) m = re.findall(r"[-+]?[0-9]+(?:\.[0-9]+)?", str(text).replace(",", "")) return float(m[-1]) if m else None def close(a, b, tol=1e-6) -> bool: if a is None or b is None: return a == b return abs(float(a) - float(b)) <= tol class Diffs: def __init__(self): self.items: list[str] = [] def check(self, where: str, expect, got): e, g = num(expect) if not isinstance(expect, (list, tuple)) else expect, got if isinstance(expect, (list, tuple)): for i, (ev, gv) in enumerate(zip(expect, got)): if not close(num(ev), num(gv)): self.items.append(f"{where}[{i}]: 원문 {ev} != 정본 {gv}") if len(expect) != len(got): self.items.append(f"{where}: 개수 다름 원문 {len(expect)} != 정본 {len(got)}") return if not close(e, g): self.items.append(f"{where}: 원문 {expect} != 정본 {got}") def note(self, text: str): self.items.append(f"[참고] {text}") def load_ws(): import openpyxl wb = openpyxl.load_workbook(XLSX_PATH, data_only=True) return wb[SHEET] def load_json() -> dict: return json.loads(RATE_JSON.read_text(encoding="utf-8")) def row_by_key(data: dict, key: str) -> dict: for r in data["줄"]: if r["키"] == key or r.get("원문번호") == key: return r raise KeyError(key) # ---- 스칼라 요율 (법정 보험료 계열) -------------------------------------- def check_scalars(ws, data: dict, d: Diffs): scalars = [ ("RT000001", "BZ51", "산재보험료"), ("RT000003", "AU51", "건강보험료"), ("RT000005", "BD51", "노인장기요양보험료"), ("RT000011", "AU110", "퇴직공제부금비"), ("RT000012", "CS99", "임금채권보장분담금"), ("RT000013", "BZ99", "석면피해구제분담금"), ] for key, cell, name in scalars: row = row_by_key(data, key) src = num(ws[cell].value) got = row["값"].get("rate_percent") d.check(f"{name}({key} {cell})", src, got) # ---- 고용보험료 등급별 ----------------------------------------------------- def check_goyong(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000002") brackets = row["값"]["brackets"] by_grade = {b["grade"]: b for b in brackets if isinstance(b.get("grade"), int)} grade_cells = [ (1, "AH67"), (2, "AH69"), (3, "AH71"), (4, "AH73"), (5, "AH75"), (6, "AH77"), (7, "AH79"), ] for grade, cell in grade_cells: src = num(ws[cell].value) got = by_grade.get(grade, {}).get("rate_percent") d.check(f"고용보험료 {grade}등급({cell})", src, got) # ---- 국민연금 — 국민연금법 부칙 제4조① ----------------------------------- def check_pension(data: dict, d: Diffs): text = PENSION_XML.read_text(encoding="utf-8") # 제4조(연금보험료에 관한 특례) ① 항 — 연도별 "1만분의 NNN" m = re.search(r"연금보험료에 관한 특례.*?(?=제5조)", text, re.S) if not m: d.note("국민연금법 XML에서 부칙 제4조를 못 찾음 — 대조 건너뜀") return body = m.group(0) found = dict(re.findall(r"(20\d\d)년은 1만분의 (\d+)", body)) row = row_by_key(data, "RT000004") annual = {r["year"]: r["rate_percent"] for r in row["값"]["annual_rates"]} for year_str, val_str in found.items(): year = int(year_str) src_rate = int(val_str) / 100 # 1만분의 475 = 4.75% got = annual.get(year) d.check(f"국민연금보험료 {year}년(부칙 제4조①)", src_rate, got) max_year_in_law = max(int(y) for y in found) unseen = [y for y in annual if y > max_year_in_law] if unseen: d.items.append( f"국민연금보험료: 법 부칙(2026~{max_year_in_law}년만 명시)에 없는 연도 " f"{sorted(unseen)}가 정본에 있음 — 2033년이 되면 그때 정할 것, 지금 미리 넣지 않음" ) # ---- 산업안전보건관리비 ----------------------------------------------------- def check_safety(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000006") brackets = row["값"]["brackets"] groups = [ ("lt_500_million", [19, 21, 23, 25], None), ("500_million_to_5_billion", [27, 29, 31, 33], "FA"), ("gte_5_billion_below_manager_threshold", [35, 37, 39, 41], None), ("gte_5_billion_at_or_above_manager_threshold", [43, 45, 47, 49], None), ] work_types = ["building", "civil", "heavy_construction", "special_construction"] idx = 0 for amount_bracket, rows, base_col in groups: for i, r in enumerate(rows): work_type = work_types[i] src_rate = num(ws[f"ES{r}"].value) match = next( b for b in brackets if b["target_amount_bracket"] == amount_bracket and b["work_type"] == work_type ) d.check( f"산업안전보건관리비 {amount_bracket}/{work_type}(ES{r})", src_rate, match["rate_percent"], ) if base_col: src_base_cheonwon = num(ws[f"{base_col}{r}"].value) src_base = ( src_base_cheonwon * 1000 if src_base_cheonwon is not None else None ) # 원문 단위=천원 d.check( f"산업안전보건관리비 기초액 {amount_bracket}/{work_type}(FA{r})", src_base, match.get("base_amount_krw"), ) idx += 1 # ---- 간접노무비 · 기타경비 (공통 5규모 x 4기간 x 3공종) -------------------- SCALE_ROWS = [ ("lt_1_billion", 15), ("1_to_5_billion", 23), ("5_to_30_billion", 31), ("30_to_100_billion", 39), ("gte_100_billion", 47), ] DURATION_OFFSETS = [ ("lte_183_days", 0), ("184_to_365_days", 2), ("366_to_1095_days", 4), ("gte_1096_days", 6), ] WORK_TYPES_3 = ["civil", "landscape", "industrial_facilities_civil"] def check_labor_expense_table(ws, brackets, cols, label, d: Diffs): by_key = { (b["direct_cost_bracket"], b["duration_bracket"], b["work_type"]): b for b in brackets } for scale_key, start_row in SCALE_ROWS: for dur_key, offset in DURATION_OFFSETS: r = start_row + offset for work_type, col in zip(WORK_TYPES_3, cols): src = num(ws[f"{col}{r}"].value) got = by_key.get((scale_key, dur_key, work_type), {}).get("rate_percent") d.check(f"{label} {scale_key}/{dur_key}/{work_type}({col}{r})", src, got) def check_indirect_labor(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000008") check_labor_expense_table(ws, row["값"]["brackets"], ["AC", "AF", "AI"], "간접노무비", d) def check_other_expense(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000009") check_labor_expense_table(ws, row["값"]["brackets"], ["AL", "AO", "AR"], "기타경비", d) # ---- 환경보전비 ------------------------------------------------------------ def check_environment(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000010") all_types = {t["work_type"]: t["rate_percent"] for t in row["값"]["all_work_types"]} cells = [ ("civil_road", "BU68"), ("civil_plant", "BU70"), ("civil_subway", "BU72"), ("civil_railway", "BU74"), ("civil_water_and_sewer", "BU76"), ("civil_port", "BU78"), ("civil_port_with_silt_screen", "BU80"), ("civil_dam", "BU82"), ("civil_land_development", "BU84"), ("civil_other", "BU86"), ("landscape", "BU88"), ("building_housing_redevelopment", "BU90"), ("building_new_housing", "BU92"), ("building_other", "BU94"), ] for work_type, cell in cells: src = num(ws[cell].value) d.check(f"환경보전비 {work_type}({cell})", src, all_types.get(work_type)) # ---- 하도급대금 지급보증 수수료 -------------------------------------------- def check_subcontract_guarantee(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000014") brackets = row["값"]["brackets"] cells = ["BZ68", "BZ71", "BZ74", "BZ77", "BZ80", "BZ83"] rate_cells = ["DC68", "DC71", "DC74", "DC77", "DC80", "DC83"] for i, (bc, rc) in enumerate(zip(cells, rate_cells)): src = num(ws[rc].value) got = brackets[i]["rate_percent"] if i < len(brackets) else None d.check(f"하도급대금 지급보증 {i}번째({rc})", src, got) if len(cells) != len(brackets): d.items.append(f"하도급대금 지급보증: 개수 다름 원문 {len(cells)} != 정본 {len(brackets)}") # ---- 건설기계대여대금 지급보증 수수료 -------------------------------------- def check_equipment_guarantee(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000015") val = row["값"] general = val["general_construction"] general_cells = ["CI19", "CI23", "CI27"] for i, cell in enumerate(general_cells): src = num(ws[cell].value) got = general[i]["rate_percent"] if i < len(general) else None d.check(f"건설기계대여대금(종합) {i}번째({cell})", src, got) specialty = val["specialty_construction"] specialty_cells = ["DE19", "DE23", "DE27", "DE31", "DE37"] for i, cell in enumerate(specialty_cells): src = num(ws[cell].value) got = specialty[i]["rate_percent"] if i < len(specialty) else None d.check(f"건설기계대여대금(전문) {i}번째({cell})", src, got) # ---- 공사이행보증 수수료 (구간별 산식) -------------------------------------- def parse_formula_numbers(text: str) -> tuple[float, float, float] | None: """(기초액, 문턱값, 퍼센트) — 만원·억원 단위를 원 단위로 바꾼 뒤 뽑음.""" if text is None: return None t = str(text) t = re.sub(r"([\d,]+)억원", lambda m: str(int(float(m.group(1).replace(",", "")) * 1e8)), t) t = re.sub(r"([\d,]+)만원", lambda m: str(int(float(m.group(1).replace(",", "")) * 1e4)), t) pct = re.search(r"([\d.]+)\s*%", t) if not pct: return None rate = float(pct.group(1)) base = re.search(r"(? len(specialty_cells): d.note( f"일반관리비(전문) 뒤 {len(specialty) - len(specialty_cells)}구간(30~100억·100억이상)은 " "원문에 별도 셀이 없음(BL35 값을 그대로 늘려 쓴 정본 비고와 일치) — 대조 대상 아님" ) def check_profit(ws, data: dict, d: Diffs): row = row_by_key(data, "RT000018") brackets = row["값"]["brackets"] cells = ["BS15", "BS31", "BS39", "BS43"] for i, cell in enumerate(cells): src = num(ws[cell].value) got = brackets[i]["rate_percent"] if i < len(brackets) else None d.check(f"이윤 {i}번째({cell})", src, got) # ---- 부가가치세 — 원문(제비율 XLSX·국민연금법) 밖의 값 --------------------- def check_vat(data: dict, d: Diffs): row_by_key(data, "RT000019") d.note( "부가가치세(RT000019)는 부가가치세법 세율 — 이 원문(조달청 제비율·국민연금법) 대상 아님, 건너뜀" ) def run_check() -> Diffs: ws = load_ws() data = load_json() d = Diffs() check_scalars(ws, data, d) check_goyong(ws, data, d) check_pension(data, d) check_safety(ws, data, d) check_indirect_labor(ws, data, d) check_other_expense(ws, data, d) check_environment(ws, data, d) check_subcontract_guarantee(ws, data, d) check_equipment_guarantee(ws, data, d) check_performance_guarantee(ws, data, d) check_overhead(ws, data, d) check_profit(ws, data, d) check_vat(data, d) return d def main() -> None: sys.stdout.reconfigure(encoding="utf-8") ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( "--check", action="store_true", help="원문과 정본 대조만(기본값과 같음 — 쓰기 기능 없음)" ) ap.parse_args() d = run_check() errors = [x for x in d.items if not x.startswith("[참고]")] notes = [x for x in d.items if x.startswith("[참고]")] for n in notes: print(n) if errors: print(f"다름 — {len(errors)}건") for e in errors: print(" ", e) sys.exit(1) print("같음 — 대조 대상 전부 원문과 일치") sys.exit(0) if __name__ == "__main__": main()