feat(805-1): M01 요율 「시트 | 리스트」 보기 단추 · 우클릭 서로 이동 · 시트 ↔ RT 전수 대조

- 요율 화면 오른쪽 위 보기 단추 둘(기본 시트 · 세션 기억) — 한 번에 하나만 보임
- 우클릭: 리스트 줄 「시트에서 보기」 · 시트 칸 「리스트에서 보기」 · 대응 없으면 흐림 + 대응 요율 없음(메뉴 부품에 disabled 항목 · 시트 부품에 cellMenu 옵션)
- 시트 칸 → RT 줄 찾기 순수 함수(UI_RateSheet_Map.ts) · 병합 칸 보정 · 겹치면 좁은 범위
- 책 대응 범위를 상자 표제 칸까지 넓힘(RT000004 값 칸 BR51 누락 바로잡음) — 값 · RT 정본 그대로
- 대조 스크립트 audit_요율대조.py(RT 잎 216 · 시트 칸 없음 8 · 시트에만 있는 칸 1 · B09 사용 표시)
- 시험: test_805_1_rate_views 신설 · test_798_1 대응표 전수 추가 · 옛 「줄 누르면 이동」은 우클릭 메뉴로 대체

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 16:15:08 +09:00
co-authored by Claude Sonnet 5.5
parent 21bba5a027
commit 7c2efde22f
13 changed files with 915 additions and 76 deletions
@@ -0,0 +1,404 @@
"""805-1 요율 대조 — 조달청 제비율 시트(본 시트) 값 칸 ↔ RT 표(요율_조달청제비율.json) 전수 대조.
읽기만(정본 JSON · 책을 쓰지 않음). 값 지어 넣기 없음 — 목록만 만든다.
python audit_요율대조.py → 요약 출력
python audit_요율대조.py --xlsx 경로 → 목록 xlsx 로 저장
결과 3묶음:
leaves RT 잎(율 · 기초액 · 식) 하나마다 시트 칸 짝 · 값 같음 / 다름 / 시트 칸 없음
orphans 시트의 값 칸 가운데 어느 RT 잎에도 안 짝지어진 것
rows RT 줄마다 B09 가 쓰는 길(자동 / 후보 글만 / 안 씀)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterator
ROOT = Path(__file__).resolve().parents[3]
BOOK = ROOT / "resources/master_data/제비율엑셀/조달청_제비율_책.json"
RATES = ROOT / "resources/master_data/요율_조달청제비율.json"
MAIN = "s1"
NUM = re.compile(r"\d+(?:\.\d+)?")
def col_index(name: str) -> int:
n = 0
for ch in name:
n = n * 26 + ord(ch) - 64
return n
def col_name(n: int) -> str:
out = ""
while n:
n, rem = divmod(n - 1, 26)
out = chr(65 + rem) + out
return out
def split(a1: str) -> tuple[str, int]:
m = re.fullmatch(r"([A-Z]+)(\d+)", a1)
assert m, a1
return m.group(1), int(m.group(2))
def load() -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]]]:
book = json.loads(BOOK.read_text(encoding="utf-8"))
sheet = next(s for s in book["책"]["시트"] if s["id"] == MAIN)
rates = json.loads(RATES.read_text(encoding="utf-8"))["줄"]
return book, sheet, rates
def cell_text(sheet: dict[str, Any], a1: str) -> Any:
return sheet["칸"].get(a1, {}).get("값")
def cell_number(sheet: dict[str, Any], a1: str) -> float | None:
"""숫자 칸 = 그 값 · 글 칸 = 마지막 수(예 「(노) x 3.56」 → 3.56 · 「(1.8)」 → 1.8)."""
v = cell_text(sheet, a1)
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v)
nums = NUM.findall(v) if isinstance(v, str) else []
return float(nums[-1]) if nums else None
# ---- RT 잎 → 시트 칸 짝 (시트 배치 규칙 · 줄 간격은 시트 그림대로) -------------------
def _leaves(key: str, value: dict[str, Any]) -> Iterator[tuple[str, str, Any, str | None, str]]:
"""(잎 이름, 종류, RT 값, 시트 칸, 비고) — 칸 None = 시트에 칸 없음."""
note = ""
if key in ("RT000001", "RT000003", "RT000005", "RT000011", "RT000012", "RT000013"):
cell = {
"RT000001": "BZ51",
"RT000003": "AU51",
"RT000005": "BD51",
"RT000011": "AU110",
"RT000012": "CS99",
"RT000013": "BZ99",
}[key]
yield "율", "율", value["rate_percent"], cell, "시트 칸은 「(노) x 율」 글"
if key == "RT000011":
yield (
"추정금액 하한",
"문구:1억",
value["minimum_estimated_amount_krw"],
"AU113",
"시트에는 글로만 있음",
)
elif key == "RT000002":
for i, b in enumerate(value["brackets"]):
g = b["grade"]
if isinstance(g, int):
yield f"{g}등급", "율", b["rate_percent"], f"AH{67 + 2 * (g - 1)}", note
else:
yield (
"7등급 미만",
"율",
b["rate_percent"],
None,
"시트에 별도 칸 없음(7등급 칸이 아래로 병합)",
)
elif key == "RT000004":
for i, r in enumerate(value["annual_rates"]):
yield (
f"{r['year']}년",
"율",
r["rate_percent"],
"BR51" if i == 0 else None,
"" if i == 0 else "시트엔 해당 연도 칸 없음(국민연금법 부칙)",
)
elif key == "RT000006":
for i, b in enumerate(value["brackets"]):
bi, wi = divmod(i, 4)
label = f"{b['target_amount_bracket']}/{b['work_type']}"
yield label, "율", b["rate_percent"], f"ES{19 + 2 * (4 * bi + wi)}", ""
if "base_amount_krw" in b:
base = b["base_amount_krw"]
yield f"{label} 기초액", "금액", base, f"FA{27 + 2 * wi}", "시트는 천원 단위"
yield (
"총 공사금액 하한",
"문구:2천만원",
value["minimum_total_construction_amount_krw"],
"DK51",
"시트에는 글로만 있음",
)
yield (
"관리자 선임(기본)",
"문구:800억",
value["manager_thresholds"]["default_estimated_amount_krw"],
"DR35",
"시트에는 글로만 있음",
)
yield (
"관리자 선임(토목 주공종)",
"문구:1,000억",
value["manager_thresholds"]["civil_main_work_estimated_amount_krw"],
"DR35",
"시트에는 글로만 있음",
)
elif key == "RT000007":
yield "기초액(참조 줄)", "참조", None, None, "파생: 값 없이 RT000006 기초액을 가리키는 줄"
elif key in ("RT000008", "RT000009"):
works = ["civil", "landscape", "industrial_facilities_civil"]
cols = ["AC", "AF", "AI"] if key == "RT000008" else ["AL", "AO", "AR"]
for b in value["brackets"]:
d = [
"lt_1_billion",
"1_to_5_billion",
"5_to_30_billion",
"30_to_100_billion",
"gte_100_billion",
]
t = ["lte_183_days", "184_to_365_days", "366_to_1095_days", "gte_1096_days"]
di, ti = d.index(b["direct_cost_bracket"]), t.index(b["duration_bracket"])
wi = works.index(b["work_type"])
yield (
f"{b['direct_cost_bracket']}/{b['duration_bracket']}/{b['work_type']}",
"율",
b["rate_percent"],
f"{cols[wi]}{15 + 2 * (4 * di + ti)}",
"",
)
elif key == "RT000010":
for i, w in enumerate(value["all_work_types"]):
yield (
w["work_type"],
"율",
w["rate_percent"],
f"BU{68 + 2 * i}",
"시트 칸은 「(1.8)」 글" if i == 6 else "",
)
for w in value["forest_road_candidates"]:
yield (
f"임도 후보 {w['work_type']}",
"율",
w["rate_percent"],
None,
"파생: all_work_types 에서 뽑은 후보(시트 칸은 그쪽 잎)",
)
elif key == "RT000014":
for i, b in enumerate(value["brackets"]):
yield b["estimated_price_bracket"], "율", b["rate_percent"], f"DC{68 + 3 * i}", ""
elif key == "RT000015":
for i, b in enumerate(value["general_construction"]):
yield f"종합 {b['work_type']}", "율", b["rate_percent"], f"CI{19 + 4 * i}", ""
for i, b in enumerate(value["specialty_construction"]):
row = [19, 23, 27, 31, 37][i]
yield f"전문 {b['work_type']}", "율", b["rate_percent"], f"DE{row}", ""
elif key == "RT000016":
for i, b in enumerate(value["brackets"]):
yield (
b["direct_cost_bracket"],
"식",
b["formula"],
f"P{[98, 101, 104, 107, 110][i]}",
"식 글 안의 수로 대조",
)
elif key == "RT000017":
for i, b in enumerate(value["civil_landscape_industrial"]):
yield (
f"종합 {b['estimated_price_bracket']}",
"율",
b["rate_percent"],
f"BD{[15, 31, 39, 43][i]}",
"",
)
# RT 전문 6구간(…십억 단위 라벨) → 시트 BL 병합 칸 4개 — 구간 둘이 한 칸을 같이 씀
for i, b in enumerate(value["specialty_electric_communication_fire_other"]):
yield (
f"전문 {b['estimated_price_bracket']}",
"율",
b["rate_percent"],
f"BL{[15, 21, 27, 35, 35, 35][i]}",
"시트 병합 칸 하나가 여러 구간",
)
elif key == "RT000018":
for i, b in enumerate(value["brackets"]):
yield (
b["estimated_price_bracket"],
"율",
b["rate_percent"],
f"BS{[15, 31, 39, 43][i]}",
"",
)
elif key == "RT000019":
yield "율", "율", value["rate_percent"], None, "부가세는 이 엑셀 밖"
def _numbers_of(v: Any) -> list[float]:
return [float(x) for x in NUM.findall(str(v).replace(",", ""))] if v is not None else []
def _same(kind: str, rt: Any, sheet_text: Any, cell_val: float | None) -> bool:
if kind == "율":
return cell_val is not None and abs(float(rt) - cell_val) < 1e-9
if kind == "금액": # 시트는 천원 단위
return cell_val is not None and abs(float(rt) - cell_val * 1000) < 1e-6
if kind.startswith("문구:"):
return kind[3:] in str(sheet_text)
# 식: RT 식의 수가 시트 글에 (만원 · 억원 단위로) 모두 있음
have = _numbers_of(sheet_text)
return all(
any(abs(n / m - h) < 1e-9 for h in have for m in (1, 10_000, 100_000_000))
for n in _numbers_of(rt)
)
# ---- B09 가 쓰는 길 ---------------------------------------------------------------
B09_AUTO = { # B09_Estimation_Rates.rate_table() 가 자동으로 읽는 줄 → 읽는 잎
"RT000001": "전부",
"RT000002": "1~7등급(7등급 미만 제외)",
"RT000003": "전부",
"RT000004": "연도별 전부",
"RT000005": "전부",
"RT000006": "토목 · 관리자 선임 미만 구간만",
"RT000008": "토목 줄만(조경 · 산업설비 제외)",
"RT000009": "토목 줄만(조경 · 산업설비 제외)",
"RT000010": "임도 후보 둘만",
"RT000015": "종합 토목 일반만",
"RT000017": "종합(토목 · 조경 · 산업) 표만",
"RT000018": "전부",
"RT000019": "전부",
}
B09_CANDIDATE = { # 총괄 줄 후보 글(출처 풍선)에만 나옴 — 값은 사용자가 직접 넣음
"RT000011": "퇴직공제부금비",
"RT000012": "임금채권보장분담금",
"RT000013": "석면피해구제분담금",
"RT000014": "하도급대금 지급보증 수수료",
"RT000016": "공사이행보증 수수료(금액 직접 입력)",
}
def audit() -> dict[str, list[dict[str, Any]]]:
book, sheet, rates = load()
mapped: dict[str, list[str]] = {}
leaves: list[dict[str, Any]] = []
for row in rates:
key = row["키"]
for name, kind, rt, cell, note in _leaves(key, row["값"]):
entry = {
"RT": key,
"이름": row["이름"],
"잎": name,
"종류": kind,
"RT값": rt,
"시트칸": cell or "",
"시트값": "" if cell is None else cell_text(sheet, cell),
"비고": note,
}
if cell is None:
entry["판정"] = "파생" if note.startswith("파생") else "시트 칸 없음"
else:
ok = _same(kind, rt, cell_text(sheet, cell), cell_number(sheet, cell))
entry["판정"] = "같음" if ok else "값 다름"
mapped.setdefault(cell, []).append(key)
if key in B09_AUTO:
entry["B09"] = "자동 셈에 씀"
elif key in B09_CANDIDATE:
entry["B09"] = "후보 글만(값 직접 입력)"
else:
entry["B09"] = "안 씀"
leaves.append(entry)
# 시트 쪽 값 칸 가운데 짝 없는 것
cover = set(mapped)
orphans: list[dict[str, Any]] = []
for a1, cell in sheet["칸"].items():
v = cell.get("값")
if v in (None, ""):
continue
is_num = isinstance(v, (int, float)) and not isinstance(v, bool)
# 율처럼 보이는 글 칸: 「x 숫자」 · 「(숫자)」 · 「…%」 · 식 글
rate_text = isinstance(v, str) and bool(re.search(r"x\s*\d|\(\d+(\.\d+)?\)$|%|\*\s*\d", v))
if not (is_num or rate_text):
continue
if a1 in cover:
continue
orphans.append({"시트칸": a1, "시트값": v, "앞 글": _label(sheet, a1)})
rows = []
for row in rates:
key = row["키"]
own = [e for e in leaves if e["RT"] == key]
rows.append(
{
"RT": key,
"이름": row["이름"],
"잎 수": len(own),
"시트 칸 있음": sum(1 for e in own if e["시트칸"]),
"시트 칸 없음": sum(1 for e in own if not e["시트칸"]),
"값 다름": sum(1 for e in own if e["판정"] == "값 다름"),
"B09": own[0]["B09"] if own else "",
"B09가 읽는 범위": B09_AUTO.get(key) or B09_CANDIDATE.get(key, ""),
}
)
return {"leaves": leaves, "orphans": orphans, "rows": rows}
def _label(sheet: dict[str, Any], a1: str) -> str:
"""같은 행 왼쪽에서 가장 가까운 글 칸(없으면 같은 열 위쪽의 가장 가까운 글 칸)."""
c, r = split(a1)
ci = col_index(c)
for cc in range(ci - 1, 0, -1):
v = sheet["칸"].get(f"{col_name(cc)}{r}", {}).get("값")
if isinstance(v, str) and v.strip():
return v.replace("\n", " ")[:30]
for rr in range(r - 1, 0, -1):
v = sheet["칸"].get(f"{c}{rr}", {}).get("값")
if isinstance(v, str) and v.strip():
return v.replace("\n", " ")[:30]
return ""
def write_xlsx(path: Path, result: dict[str, list[dict[str, Any]]]) -> None:
import openpyxl
wb = openpyxl.Workbook()
wb.remove(wb.active)
gaps = [e for e in result["leaves"] if e["판정"] in ("시트 칸 없음", "값 다름")]
for title, rows in (
("RT줄 요약", result["rows"]),
("RT에 있고 시트 칸 없음 · 다름", gaps),
("시트에 있고 RT 짝 없음", result["orphans"]),
("RT 잎 전체", result["leaves"]),
):
ws = wb.create_sheet(title)
if not rows:
ws.append(["(없음)"])
continue
heads = list(rows[0].keys())
ws.append(heads)
for r in rows:
ws.append([r.get(h) for h in heads])
for i, h in enumerate(heads, 1):
ws.column_dimensions[col_name(i)].width = (
14 if h not in ("잎", "비고", "앞 글", "이름") else 34
)
ws.freeze_panes = "A2"
path.parent.mkdir(parents=True, exist_ok=True)
wb.save(path)
def main() -> None:
result = audit()
leaves = result["leaves"]
print("RT 잎", len(leaves), "· 같음", sum(e["판정"] == "같음" for e in leaves))
print(
"시트 칸 없음",
sum(e["판정"] == "시트 칸 없음" for e in leaves),
"· 값 다름",
sum(e["판정"] == "값 다름" for e in leaves),
)
print("시트에만 있는 값 칸", len(result["orphans"]))
if "--xlsx" in sys.argv:
out = Path(sys.argv[sys.argv.index("--xlsx") + 1])
write_xlsx(out, result)
print("저장", out)
if __name__ == "__main__":
main()
@@ -53,26 +53,28 @@ BORDERS = {
H_ALIGN = {"general", "left", "center", "right", "distributed", "centerContinuous"}
V_ALIGN = {"top", "center", "bottom"}
# RT 줄 → 엑셀 칸(대조 칸 · 눌렀을 때 가서 칠할 칸). 부가가치세(RT000019)는 이 엑셀에 없음.
# RT 줄 → 엑셀 칸(눌렀을 때 가서 칠할 상자 · 우클릭으로 거꾸로 찾는 범위). 첫 칸 = 상자 표제 칸 ·
# 805-1 에서 표제 칸까지 넓힘(시트 표제 칸을 우클릭해도 RT 줄을 찾도록 · 값 대조 칸은 `build_요율.py` 쪽).
# 부가가치세(RT000019)는 이 엑셀에 없음.
RT_CELLS: dict[str, list[str]] = {
"RT000001": ["BZ51"],
"RT000002": ["AH67", "AH79"],
"RT000003": ["AU51"],
"RT000004": ["BR47"], # [연금보험료] 표제 칸
"RT000005": ["BD51"],
"RT000006": ["ES19", "ES49", "FA27", "FA33"],
"RT000001": ["BZ47", "BZ51"],
"RT000002": ["B59", "AH82"],
"RT000003": ["AU47", "AU51"],
"RT000004": ["BR47", "BR51"],
"RT000005": ["BD47", "BD51"],
"RT000006": ["DK3", "FA49"],
"RT000007": ["FA27", "FA33"],
"RT000008": ["AC15", "AI53"],
"RT000009": ["AL15", "AR53"],
"RT000010": ["BU68", "BU94"],
"RT000011": ["AU110"],
"RT000012": ["CS99"],
"RT000013": ["BZ99"],
"RT000014": ["BZ68", "DC83"],
"RT000015": ["CI19", "DE37"],
"RT000016": ["P98", "P110"],
"RT000017": ["BD15", "BL43"],
"RT000018": ["BS15", "BS43"],
"RT000008": ["AC3", "AI53"],
"RT000009": ["AL3", "AR53"],
"RT000010": ["AU59", "BU94"],
"RT000011": ["AU106", "AU110"],
"RT000012": ["CS96", "CS99"],
"RT000013": ["BZ96", "BZ99"],
"RT000014": ["BZ59", "DC83"],
"RT000015": ["BZ3", "DE37"],
"RT000016": ["B91", "P110"],
"RT000017": ["BD3", "BL43"],
"RT000018": ["BS3", "BS43"],
}
File diff suppressed because one or more lines are too long