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:
@@ -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()
|
||||
Reference in New Issue
Block a user