knowledge(master_data): 요율 갱신절차 구멍 메움 - build_요율.py 대조기 추가

조달청제비율 XLSX·국민연금법 XML 원문을 요율_조달청제비율.json 과 셀 단위로 대조하는
--check 전용 스크립트 신설(정본 쓰기 기능 없음) · 지금 원문·정본 전부 일치 확인 ·
국민연금 2033년 이후 요율은 법 부칙(2026~2032만 명시)에 없는 추정치임을 참고로 남김 ·
_갱신절차.md 요율 줄·구멍 목록 갱신 · 시험 3건 추가.
This commit is contained in:
2026-09-20 23:09:58 +09:00
parent 53151917ba
commit 4f9027b955
3 changed files with 475 additions and 3 deletions
+4 -3
View File
@@ -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` |
## 로직 (`로직_<원문>_<NN>장_….json`)
@@ -129,7 +129,8 @@
## 구멍 목록
- 요율 · 품셈재료 · 로직 — 빌더 없음.
- 품셈재료 · 로직 — 빌더 없음.
- 요율 — 대조(`--check`)만 됨 · 값을 정본에 반영하는 쓰기는 여전히 손으로.
- 기계 산림품셈 · 소요량 건설 건축·유지관리 · 소요량 산림 전 장 · 계수 건설 전 장 · 계수 산림 1장 밖 — 빌더 없음.
- 자재품목 — 나라장터 스냅샷 → 마스터 줄 빌더 없음 · 유료 물가지 받는 길 미정.
- 유가전력 — 휘발유·경유 새 판 반영 없음.
@@ -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"(?<![\d.])(\d{4,})\s*\+", t)
base_amt = float(base.group(1)) if base else 0.0
thresh = re.search(r"-\s*(\d{4,})\s*\)", t)
thresh_amt = float(thresh.group(1)) if thresh else 0.0
return base_amt, thresh_amt, rate
def check_performance_guarantee(ws, data: dict, d: Diffs):
row = row_by_key(data, "RT000016")
brackets = row[""]["brackets"]
cells = ["P98", "P101", "P104", "P107", "P110"]
for i, cell in enumerate(cells):
src = parse_formula_numbers(ws[cell].value)
got_formula = brackets[i]["formula"] if i < len(brackets) else None
got = parse_formula_numbers(got_formula)
where = f"공사이행보증 {i}번째({cell})"
if src is None or got is None:
d.items.append(f"{where}: 산식 못 읽음 원문={ws[cell].value!r} 정본={got_formula!r}")
continue
for label, sv, gv in zip(("기초액", "문턱값", "퍼센트"), src, got):
if not close(sv, gv, tol=1e-4):
d.items.append(f"{where} {label}: 원문 {sv} != 정본 {gv}")
# ---- 일반관리비 · 이윤 ------------------------------------------------------
def check_overhead(ws, data: dict, d: Diffs):
row = row_by_key(data, "RT000017")
val = row[""]
civil = val["civil_landscape_industrial"]
civil_cells = ["BD15", "BD31", "BD39", "BD43"]
for i, cell in enumerate(civil_cells):
src = num(ws[cell].value)
got = civil[i]["rate_percent"] if i < len(civil) else None
d.check(f"일반관리비(종합) {i}번째({cell})", src, got)
specialty = val["specialty_electric_communication_fire_other"]
specialty_cells = ["BL15", "BL21", "BL27", "BL35"] # 원문에 값 있는 앞 4구간만
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)
if len(specialty) > 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()
+44
View File
@@ -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)