From 4f9027b9556a2cabcdffc1ea6054fa62bef0f74c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 23:09:58 +0900 Subject: [PATCH] =?UTF-8?q?knowledge(master=5Fdata):=20=EC=9A=94=EC=9C=A8?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0=EC=A0=88=EC=B0=A8=20=EA=B5=AC=EB=A9=8D=20?= =?UTF-8?q?=EB=A9=94=EC=9B=80=20-=20build=5F=EC=9A=94=EC=9C=A8.py=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조달청제비율 XLSX·국민연금법 XML 원문을 요율_조달청제비율.json 과 셀 단위로 대조하는 --check 전용 스크립트 신설(정본 쓰기 기능 없음) · 지금 원문·정본 전부 일치 확인 · 국민연금 2033년 이후 요율은 법 부칙(2026~2032만 명시)에 없는 추정치임을 참고로 남김 · _갱신절차.md 요율 줄·구멍 목록 갱신 · 시험 3건 추가. --- resources/master_data/_갱신절차.md | 7 +- resources/master_data/scripts/build_요율.py | 427 ++++++++++++++++++++ resources/tester/test_build_요율.py | 44 ++ 3 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/build_요율.py create mode 100644 resources/tester/test_build_요율.py diff --git a/resources/master_data/_갱신절차.md b/resources/master_data/_갱신절차.md index a399abdc..bebf3689 100644 --- a/resources/master_data/_갱신절차.md +++ b/resources/master_data/_갱신절차.md @@ -113,8 +113,8 @@ | 출처 | 조달청제비율 · `행정규칙/토목공사원가계산 제비율 적용기준/첨부/*조달청_토목공사_제비율.xlsx` + 국민연금법 `법률/국민연금법/현행_*.xml` | | 받는 법 | 조달청 고시 첨부 XLSX · 법령 XML 손으로 (`_pipeline/collect_law.py` 는 법령 쪽) | | 주기 | 고시 개정 때(연 1~2회) · 국민연금 요율은 매년 | -| 빌더 | 빌더 없음 (`scripts/_migrate_base.py` 는 옛 파일 한 번 옮기기) | -| 검사 | `check_master.py 틀 요율_조달청제비율.json` | +| 빌더 | `scripts/build_요율.py --check`(대조만 · 정본 안 씀 · 다른 줄은 원문 셀 자리와 같이 나옴) — `scripts/_migrate_base.py` 는 옛 파일 한 번 옮기기 | +| 검사 | `build_요율.py --check` · `check_master.py 틀 요율_조달청제비율.json` | ## 로직 (`로직_<원문>_장_….json`) @@ -129,7 +129,8 @@ ## 구멍 목록 -- 요율 · 품셈재료 · 로직 — 빌더 없음. +- 품셈재료 · 로직 — 빌더 없음. +- 요율 — 대조(`--check`)만 됨 · 값을 정본에 반영하는 쓰기는 여전히 손으로. - 기계 산림품셈 · 소요량 건설 건축·유지관리 · 소요량 산림 전 장 · 계수 건설 전 장 · 계수 산림 1장 밖 — 빌더 없음. - 자재품목 — 나라장터 스냅샷 → 마스터 줄 빌더 없음 · 유료 물가지 받는 길 미정. - 유가전력 — 휘발유·경유 새 판 반영 없음. diff --git a/resources/master_data/scripts/build_요율.py b/resources/master_data/scripts/build_요율.py new file mode 100644 index 00000000..28943f2d --- /dev/null +++ b/resources/master_data/scripts/build_요율.py @@ -0,0 +1,427 @@ +# -*- 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.note( + f"국민연금보험료 {sorted(unseen)}년 값은 법 부칙(2026~{max_year_in_law}년만 명시)에 없음 — " + f"정본의 rate_from_{unseen[0]}_percent={annual[unseen[0]]} 는 뒤 연도 추정치, 개정 시 재확인 필요" + ) + + +# ---- 산업안전보건관리비 ----------------------------------------------------- + + +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() diff --git a/resources/tester/test_build_요율.py b/resources/tester/test_build_요율.py new file mode 100644 index 00000000..e0b43572 --- /dev/null +++ b/resources/tester/test_build_요율.py @@ -0,0 +1,44 @@ +"""build_요율.py 시험 — 2026-09-20 브레인 일감(랩탑 메인). + +못박는 것 + - 지금 원문(XLSX·국민연금법 XML)과 정본 요율_조달청제비율.json 이 같음(회귀 기준). + - 정본 값을 하나 비틀면 --check 가 실제로 다름을 잡아냄(대조 로직이 항상 통과만 하는 게 아님). +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) + +import build_요율 as bu # noqa: E402 + + +def test_지금_원문과_정본이_같음() -> None: + d = bu.run_check() + errors = [x for x in d.items if not x.startswith("[참고]")] + assert not errors, f"원문과 정본이 다름 — {errors}" + + +def test_값을_비틀면_다름을_잡아냄() -> None: + ws = bu.load_ws() + data = copy.deepcopy(bu.load_json()) + row = bu.row_by_key(data, "RT000001") + row["값"]["rate_percent"] = row["값"]["rate_percent"] + 1 # 산재보험료를 일부러 어긋냄 + d = bu.Diffs() + bu.check_scalars(ws, data, d) + assert any("산재보험료" in x for x in d.items) + + +def test_고용보험료_등급_어긋내면_잡아냄() -> None: + ws = bu.load_ws() + data = copy.deepcopy(bu.load_json()) + row = bu.row_by_key(data, "RT000002") + grade1 = next(b for b in row["값"]["brackets"] if b.get("grade") == 1) + grade1["rate_percent"] = 0.0 + d = bu.Diffs() + bu.check_goyong(ws, data, d) + assert any("1등급" in x for x in d.items)