feat(798-1): M01 요율 조달청 제비율 내장 엑셀 — 빌더 · 읽기 전용 화면 · RT 줄 → 칸 이동

- build_제비율책.py: 조달청 xlsx → 내장 엑셀 책 JSON(병합 · 채움 · 글꼴 · 테두리 · 열 폭 · 행 높이 · 시트 둘 · 수식 결과) · --check(판 바뀜 · RT 칸 = 정본 값)
- 제비율엑셀/조달청_제비율_책.json · /api/m01/rate-sheet · 요율 화면 위 엑셀 + 아래 RT 표 · 확대 · 축소
- 정본 요율_조달청제비율.json · B09 요율 셈 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:20:32 +09:00
co-authored by Claude Sonnet 5.5
parent c48580d6c9
commit 29e60a3ad6
11 changed files with 659 additions and 1 deletions
@@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
"""조달청 제비율 xlsx → 내장 엑셀 책 JSON (798-1 · 판마다 한 번 돌림).
돌리기:
./venv/Scripts/python.exe resources/master_data/scripts/build_제비율책.py (책 JSON 쓰기)
./venv/Scripts/python.exe resources/master_data/scripts/build_제비율책.py --check (읽기만)
결과 = `resources/master_data/제비율엑셀/조달청_제비율_책.json`
{종류 · 원천(파일 · sha256) · 대응(RT 키 → 시트 · 칸 · 범위) · 책(내장 엑셀 Workbook)}
M01 요율 화면이 읽기만 함 — 정본 `요율_조달청제비율.json`(RT000001~019) 은 건드리지 않음.
⚠ 제비율 폴더 이름 `제비율엑셀` — M01 파일 찾기(`요율_*.json` 글롭)에 안 걸리게 따로 둠.
--check = ① 다시 지은 책이 저장된 책과 같은가(판이 바뀌면 여기서 깨짐) ② 책 안 RT 대응 칸 값이
정본 JSON 값과 같은가(`build_요율.py` 의 셀 대응표를 그대로 씀 · 국민연금은 법 XML 쪽이라 제외).
값은 엑셀이 저장해 둔 계산 결과(수식 결과) — 수식 글은 안 옮김(읽기 전용 · 셈은 M01 정본이 함).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import build_요율 as rate_check # noqa: E402
ROOT = rate_check.ROOT
XLSX_PATH = rate_check.XLSX_PATH
OUT = ROOT / "resources/master_data/제비율엑셀/조달청_제비율_책.json"
MAIN_SHEET = rate_check.SHEET
DEF_FONT = ("맑은 고딕", 11.0)
DEF_COL_CHARS = 8.43
BORDERS = {
"thin": "thin",
"medium": "medium",
"thick": "thick",
"hair": "hair",
"double": "double",
"dotted": "dotted",
"dashed": "dashed",
"mediumDashed": "dashed",
"dashDot": "dashed",
"mediumDashDot": "dashed",
"dashDotDot": "dashed",
"mediumDashDotDot": "dashed",
"slantDashDot": "dashed",
}
H_ALIGN = {"general", "left", "center", "right", "distributed", "centerContinuous"}
V_ALIGN = {"top", "center", "bottom"}
# RT 줄 → 엑셀 칸(대조 칸 · 눌렀을 때 가서 칠할 칸). 부가가치세(RT000019)는 이 엑셀에 없음.
RT_CELLS: dict[str, list[str]] = {
"RT000001": ["BZ51"],
"RT000002": ["AH67", "AH79"],
"RT000003": ["AU51"],
"RT000004": ["BR47"], # [연금보험료] 표제 칸
"RT000005": ["BD51"],
"RT000006": ["ES19", "ES49", "FA27", "FA33"],
"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"],
}
def _rgb(color) -> str | None:
"""openpyxl 색 → `#RRGGBB` (rgb 만 · 테마 · 색인은 글자색 기본 검정으로 둠)."""
if color is None or color.type != "rgb" or not isinstance(color.rgb, str):
return None
return "#" + color.rgb[-6:].upper()
def _style(cell) -> dict:
out: dict = {}
f = cell.font
if f.name and f.name != DEF_FONT[0]:
out["글꼴"] = f.name
if f.sz and float(f.sz) != DEF_FONT[1]:
out["크기"] = float(f.sz)
for key, on in (("굵게", f.b), ("기울임", f.i), ("취소선", f.strike)):
if on:
out[key] = True
if f.u:
out["밑줄"] = True
color = _rgb(f.color)
if color and color != "#000000":
out["글자색"] = color
fill = cell.fill
if fill and fill.fill_type == "solid":
bg = _rgb(fill.fgColor)
if bg and bg != "#FFFFFF":
out["채움"] = bg
a = cell.alignment
if a.horizontal in H_ALIGN and a.horizontal != "general":
out["가로"] = a.horizontal
if a.vertical in V_ALIGN:
out["세로"] = a.vertical
if a.wrap_text:
out["줄바꿈"] = True
edges = {}
for name, side in (
("위", cell.border.top),
("아래", cell.border.bottom),
("왼", cell.border.left),
("오른", cell.border.right),
):
if side is not None and side.style in BORDERS:
one = {"선": BORDERS[side.style]}
c = _rgb(side.color)
if c and c != "#000000":
one["색"] = c
edges[name] = one
if edges:
out["테두리"] = edges
if cell.number_format and cell.number_format != "General":
out["형식"] = cell.number_format
return out
def _value(v):
if v is None or isinstance(v, (bool, int, float)):
return v
return str(v)
def _sheet(sid: str, ws, ws_values, styles: list[dict], index: dict[str, int]) -> dict:
cells: dict[str, dict] = {}
for row in ws.iter_rows():
for cell in row:
val = _value(ws_values[cell.coordinate].value)
st = _style(cell)
if val is None and not ({"채움", "테두리"} & st.keys()):
continue
item: dict = {}
if val is not None:
item["값"] = val
if st:
key = json.dumps(st, ensure_ascii=False, sort_keys=True)
if key not in index:
index[key] = len(styles)
styles.append(st)
item["서식"] = index[key]
cells[cell.coordinate] = item
last_col = ws.max_column
cols: dict[str, dict] = {}
for dim in ws.column_dimensions.values():
if dim.width is None:
continue
lo, hi = dim.min or 1, min(dim.max or dim.min or 1, last_col)
for c in range(lo, hi + 1):
cols[_col_name(c)] = {"폭": float(dim.width)}
rows: dict[str, dict] = {}
for r, dim in ws.row_dimensions.items():
if r > ws.max_row:
continue
info = {}
if dim.height:
info["높이"] = float(dim.height)
if dim.hidden:
info["숨김"] = True
if info:
rows[str(r)] = info
fmt = ws.sheet_format
sheet: dict = {
"id": sid,
"이름": ws.title,
"칸": cells,
"병합": sorted(str(m) for m in ws.merged_cells.ranges),
"열": cols,
"행": rows,
"기본": {
"열폭": float(fmt.defaultColWidth or DEF_COL_CHARS),
"행높이": float(fmt.defaultRowHeight or 15),
},
}
if ws.sheet_view.showGridLines is False:
sheet["보기"] = {"눈금선": False}
return sheet
def _col_name(n: int) -> str:
out = ""
while n:
n, rem = divmod(n - 1, 26)
out = chr(65 + rem) + out
return out
def _col_index(name: str) -> int:
n = 0
for ch in name:
n = n * 26 + ord(ch) - 64
return n
def _split(a1: str) -> tuple[int, int]:
m = re.fullmatch(r"([A-Z]+)(\d+)", a1)
assert m, a1
return int(m.group(2)), _col_index(m.group(1))
def _span(cells: list[str]) -> str:
pts = [_split(c) for c in cells]
r0, r1 = min(p[0] for p in pts), max(p[0] for p in pts)
c0, c1 = min(p[1] for p in pts), max(p[1] for p in pts)
lo, hi = f"{_col_name(c0)}{r0}", f"{_col_name(c1)}{r1}"
return lo if lo == hi else f"{lo}:{hi}"
def build() -> dict:
import openpyxl
wb = openpyxl.load_workbook(XLSX_PATH)
wv = openpyxl.load_workbook(XLSX_PATH, data_only=True)
styles: list[dict] = [{}]
index: dict[str, int] = {}
sheets = [
_sheet(f"s{i}", ws, wv[ws.title], styles, index) for i, ws in enumerate(wb.worksheets, 1)
]
main_id = next(s["id"] for s in sheets if s["이름"] == MAIN_SHEET)
book = {
"종류": "통합문서",
"판": 1,
"열": "제비율",
"기본": {"글꼴": DEF_FONT[0], "크기": DEF_FONT[1]},
"서식": styles,
"시트": sheets,
"활성": main_id,
}
return {
"종류": "제비율책",
"원천": {
"파일": XLSX_PATH.relative_to(ROOT).as_posix(),
"sha256": hashlib.sha256(XLSX_PATH.read_bytes()).hexdigest(),
"시트": [s["이름"] for s in sheets],
},
"대응": {
key: {"시트": main_id, "칸": cells[0], "범위": _span(cells)}
for key, cells in RT_CELLS.items()
},
"책": book,
}
def dump(data: dict) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + "\n"
class _Cell:
def __init__(self, value):
self.value = value
class BookSheet:
"""책 JSON 메인 시트를 `ws["BZ51"].value` 꼴로 읽게 하는 껍데기(`build_요율` 대조 함수용)."""
def __init__(self, data: dict):
sid = data["책"]["활성"]
self.cells = next(s for s in data["책"]["시트"] if s["id"] == sid)["칸"]
def __getitem__(self, a1: str) -> _Cell:
return _Cell(self.cells.get(a1, {}).get("값"))
def check_values(data: dict) -> list[str]:
ws = BookSheet(data)
rates = rate_check.load_json()
d = rate_check.Diffs()
for fn in (
rate_check.check_scalars,
rate_check.check_goyong,
rate_check.check_safety,
rate_check.check_indirect_labor,
rate_check.check_other_expense,
rate_check.check_environment,
rate_check.check_subcontract_guarantee,
rate_check.check_equipment_guarantee,
rate_check.check_performance_guarantee,
rate_check.check_overhead,
rate_check.check_profit,
):
fn(ws, rates, d)
errors = [x for x in d.items if not x.startswith("[참고]")]
keys = {r["키"] for r in rates["줄"]}
for key, spot in data["대응"].items():
if key not in keys:
errors.append(f"대응 {key}: 정본에 없는 RT 키")
if ws.cells.get(spot["칸"], {}).get("값") is None:
errors.append(f"대응 {key}: 칸 {spot['칸']} 이 책에서 빔")
return errors
def run_check() -> list[str]:
if not OUT.exists():
return [f"책 JSON 없음 — 빌더를 먼저 돌릴 것: {OUT.relative_to(ROOT)}"]
saved = OUT.read_text(encoding="utf-8")
errors: list[str] = []
if saved != dump(build()):
errors.append("판이 바뀜 — 저장된 책이 xlsx 와 다름(빌더를 다시 돌릴 것)")
errors += check_values(json.loads(saved))
return errors
def main() -> None:
sys.stdout.reconfigure(encoding="utf-8")
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check", action="store_true", help="읽기 전용 대조(쓰기 없음)")
args = ap.parse_args()
if args.check:
errors = run_check()
if errors:
print(f"다름 — {len(errors)}건")
for e in errors:
print(" ", e)
sys.exit(1)
print("같음 — 책 = xlsx · RT 대응 칸 = 정본 값")
return
data = build()
errors = check_values(data)
if errors:
print("정본과 안 맞아 쓰지 않음:")
for e in errors:
print(" ", e)
sys.exit(1)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(dump(data), encoding="utf-8")
print(f"썼음 — {OUT.relative_to(ROOT)} ({OUT.stat().st_size:,} 바이트)")
if __name__ == "__main__":
main()