Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-09-20 23:19:40 +09:00
8 changed files with 547 additions and 7 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,37 @@
# 단위 환산 원문 · `_단위_환산_필요.md` ㉮ 18줄
본문에서 본 값만 적음. 「없음」 = 건설·산림 표준품셈 본문 전체에서 찾지 못함. 값 확정·엔진 적용은 이 문서 밖.
원문 자리 약칭: 건 = 건설공사 표준품셈 · 산 = 산림사업 표준품셈.
## 값이 있는 것
| 키 | 재료 | 필요한 환산 | 원문 자리 | 값 | 계수 키 |
|---|---|---|---|---|---|
| MP000003 | 모래 | ㎥ ↔ kg | 건 공통 1-2-4 · 3. 재료의 단위 중량 표 (건설 본문 `1-02_설계_및_수량.md`) | 건조 1,500∼1,700 · 습기 1,700∼1,800 · 포화 1,800∼2,000 ㎏/㎥ (범위값 · 자연상태) | 없음 (표가 계수 요소로 안 올라 있음) |
| MP000135 | 철근 | m ↔ ton | 같은 표 | 강·주강·단철 7,850 ㎏/㎥ 만 있음 · 철근 ㎏/m 표(호칭별 단위중량) 없음 | 없음 (산 9-8-1·9-8-2 에 「고철 단위중량 7850 kg/㎥」 요소만 있음: CF000104 · CF000105) |
| MP000185 | 실링재 | m ↔ kg | 산 12-27-3 실런트(20×25mm) | 자재 실런트 0.70 · 비고 「0.02×0.025×1400kg/㎥」 (단면 20×25㎜ · 밀도 1,400 ㎏/㎥) — 원문 단위 칸은 ㎥ 로 찍혀 식의 kg 과 어긋남 | QF000401 (소요량 · 비고 글로만) |
| MP000069 | 우드칩 | ㎥ ↔ ㎡ | 산 6-8 목재칩 포설 | ㎡당 우드칩 0.11 ㎥ (분쇄목 T10cm 기준) · 소운반 0.11 Ton 이 같은 ㎡당 표에 병기 — 환산비는 안 적혀 있음 | QF000177 (소요량) |
| MP000193 | 혼화제 | g ↔ kg | 건 9-2 콘크리트면 마무리 · 전면 마감 재료량 | 혼화제 22.7 g · 시멘트 14.3 ㎏ (같은 표 · 환산 문구 없음, g↔kg 비는 SI 정의) | QC001356 (소요량) |
## 없음
| 키 | 재료 | 필요한 환산 | 찾은 자리 | 값 |
|---|---|---|---|---|
| MP000011 | 강봉 | 개 ↔ m | 산 12-30 다웰바 설치 (강봉 EA 별도계상) | 없음 |
| MP000014 · 085 · 086 | 폼타이 | 본 ↔ 조 | 건 6-3 거푸집 [주] ② 「폼타이 사용시 소요수량은 6-3-1 합판거푸집 자재 기준」 | 없음 (1조=몇 본 문구 없음) |
| MP000022 | 잡석 | ㎥ ↔ EA | 건 1-2-3 용어 「잡석 = 지름 10∼30㎝」 | 없음 (개당 부피·무게 없음) · 단위중량 표에도 잡석 줄 없음 (사석 2,000 · 호박돌 1,800∼2,000 ㎏/㎥ 는 다른 종별) |
| MP000103 | CANOPY(출입구채양) | 매 ↔ 개 | 건 2-2 손율 표 · 「CANOPY(출입구채양) 600×1,200㎜ 매」 | 원문 단위는 매 뿐 · 개 환산 없음 (QC000012) |
| MP000107 | 중도리(PURIN) | 개 ↔ m | 건 2-2 손율 표 · 「중도리(PURIN) 두께:2.0이상」 | 길이 없음 (QC000012) |
| MP000109 | T-BAR | m ↔ kg | 건 2-2 손율 표 · 「T-BAR m 1.53」 | m 당 무게 없음 (QC000012) |
| MP000126 | 사다리(플라잉넷) | m ↔ 개 | 건 2-8-2 낙하물 방지망 재료량 | 사다리 「폭 30㎝×길이 3m 기준」 ㎡당 0.111 m · 개 환산 없음 (QC000073 · QC000074) |
| MP000219 · 300 | 규사 | ㎥ ↔ kg | 건 1-8 교통시설공 (열경화성 아크릴수지+규사 도포) | 단위중량 없음 · 단위중량 표에도 규사 줄 없음 |
| MP000252 | 기름 | ℓ ↔ 본 | 본문 전체 | 병 용량 없음 |
| MP000292 | 함석 | 대 ↔ 매 | 건 2-1 가설물의 한도 · 「함석 #28.90㎝×180㎝ 매」 | 1매 규격만 있음 · 「대」 단위 없음 (QC000004 · QC000005) |
## 그 밖에 본 값
- 자갈 ㎥ ↔ kg (`_단위_환산_필요.md` ㉰ MP000005): 같은 1-2-4 · 3. 표 — 자갈 건조 1,600∼1,800 · 습기 1,700∼1,800 · 포화 1,800∼1,900 ㎏/㎥.
- 시멘트: 같은 표 3,150(비중) · 1,500(자연상태) ㎏/㎥ · 산 10-1 시멘트운반 「40kg 포대 기준」.
- 덤프 적재 단위중량: 산 10-12 [주] ② 토사 1.9 ton/㎥ · 암절취·발파암 2.4 ton/㎥ (QF000338 · 모래 값 아님).
- 표에 없는 품종: 1-2-4 · 3. [주] ② 「단위중량 시험에 의해 결정함을 원칙」.
@@ -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()
@@ -479,7 +479,12 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
)
else:
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env)
# 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄
ref = (
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else fill(item["요소"], env)
)
try:
price, source = element(master, ref, env, item.get("단위"))
except FormulaError:
@@ -704,6 +709,7 @@ from master_material import ( # noqa: E402, F401
candidates,
check_pick,
check_unit,
static,
cond_text,
element,
pick,
@@ -106,6 +106,11 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
# ── 재료 고르기 ────────────────────────────────────────────────────────
def static(cond: dict) -> dict:
"""검사용 조건 — 값에 `{입력}` 이 든 칸은 뺌(계산 때 채워짐)."""
return {k: v for k, v in cond.items() if not (isinstance(v, str) and "{" in v)}
def cond_text(cond: dict) -> str:
"""고르기 조건 한 글자 열쇠 — 「구분|상세구분|규격|대표」 · 화면 단가 미리보기가 줄을 찾는 이름."""
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS)
@@ -147,6 +152,7 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s
def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]:
"""단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름.
틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`)."""
cond = static(cond)
rows = master.index.get(MARKET, {})
rep = str(cond.get("대표") or "")
if not unit or set(cond) - set(PICK_KEYS) or not cond.get("구분"):
@@ -163,9 +169,11 @@ def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> l
def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]:
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0."""
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0.
값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌."""
if set(cond) - set(PICK_KEYS) or not cond.get("구분"):
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
cond = static(cond)
out, rows = [], master.index.get(MARKET, {})
for slot in ("구분", "상세구분"):
if cond.get(slot) and not any(r.get(slot) == cond[slot] for r in rows.values()):
@@ -1989,7 +1989,11 @@
},
{
"종류": "재료",
"요소": "MP:흄관 ∅{관경}㎜",
"요소": {
"구분": "관·이음관",
"상세구분": "흄관",
"규격": "{관경}"
},
"이름": "흄관",
"단위": "m",
"수량": "찾기(QF000371, 구분='흄관', 관경=관경).적용",
@@ -2133,7 +2137,11 @@
"호표": [
{
"종류": "재료",
"요소": "MP:파형강관 ∅{관경}㎜",
"요소": {
"구분": "관·이음관",
"상세구분": "파형강관",
"규격": "{관경}"
},
"이름": "파형강관",
"단위": "m",
"수량": "찾기(QF000373, 구분='파형강관', 관경=관경).적용",
+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)
@@ -952,3 +952,12 @@ def test_단위_경고는_로직_검사와_따로():
assert cm.check_logics(files, whole) == []
warned = cm.check_units(files, whole)
assert warned and all("단위" in x for x in warned)
def test_고르기_조건의_입력_자리_채움(whole):
"""관경 같은 입력이 규격에 든 고르기 조건 — 계산 때 채워지고, 검사에서는 그 칸을 뺌."""
cond = {"구분": "관·이음관", "상세구분": "파형강관", "규격": "{관경}"}
assert mm.static(cond) == {"구분": "관·이음관", "상세구분": "파형강관"}
assert mf.check_pick(whole, "시험", cond) == []
채움 = {k: mf.fill(v, {"관경": Decimal(800)}) for k, v in cond.items()}
assert mm.candidates(whole, 채움, None, "m")