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
@@ -4,6 +4,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
@@ -120,6 +121,15 @@ export const fetchGroups = async (): Promise<GroupInfo[]> =>
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
/** 조달청 제비율 엑셀 책(요율 화면 · 읽기만) — 대응 = RT 키 → 대응 칸(시트 id · 첫 칸 · 칠할 범위). */
export interface RateSheetData {
원천: { 파일: string; sha256: string; 시트: string[] };
대응: Record<string, { 시트: string; 칸: string; 범위: string }>;
책: Workbook;
}
export const fetchRateSheet = (): Promise<RateSheetData> => get<RateSheetData>("/rate-sheet", {});
export const fetchRows = (
file: string,
page: number,
@@ -0,0 +1,21 @@
"""M01 요율 화면 — 조달청 제비율 엑셀 책 읽기 (798-1 · 읽기만).
책 JSON = `resources/master_data/제비율엑셀/조달청_제비율_책.json`
(빌더 `scripts/build_제비율책.py` 가 판마다 한 번 만듦 · RT 정본 값과 `--check` 대조).
요율 셈(`B09_Estimation_Rates.py`)은 이 책을 읽지 않음 — 화면 표현만.
"""
from __future__ import annotations
import json
from M01_MasterData import M01_MasterData_Store as store
BOOK = "제비율엑셀/조달청_제비율_책.json"
def load() -> dict:
path = store.FOLDER / BOOK
if not path.is_file():
raise store.StoreError(404, "제비율 엑셀 책이 없습니다 — build_제비율책.py 를 돌려 주세요")
return json.loads(path.read_text(encoding="utf-8"))
+7
View File
@@ -17,6 +17,7 @@ from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M01_MasterData import M01_MasterData_Store_List as store_list
from M01_MasterData import M01_MasterData_RateSheet as rate_sheet
from M01_MasterData import M01_MasterData_Store_Make as make
from M01_MasterData import M01_MasterData_Store_Revise as revise
from M01_MasterData import M01_MasterData_Store_Sheet as sheet
@@ -134,6 +135,12 @@ def get_files(group: str) -> dict:
return {"files": [{**f, "판정규": mbs.edition(f["edition"])} for f in files]}
@router.get("/rate-sheet")
def get_rate_sheet() -> dict:
"""요율 화면 위쪽 내장 엑셀(조달청 제비율) — 읽기만(798-1)."""
return _call(rate_sheet.load)
@router.get("/subs")
def get_subs(file: str = "", group: str = "") -> dict:
return _call(store.subs, file, group)
+4 -1
View File
@@ -33,6 +33,7 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { renderRateRows } from "./M01_MasterData_UI_RateSheet";
import {
buildSide,
fileLabel,
@@ -169,7 +170,9 @@ function buildPage(): HTMLElement {
title.textContent = groupTitle.join(" · ");
dispose = TABLE_GROUPS.includes(pick.group)
? renderTables(body, pick, query, openLogicTab)
: renderRows(body, pick, query);
: pick.group === "요율"
? renderRateRows(body, pick, query)
: renderRows(body, pick, query);
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
};
@@ -0,0 +1,27 @@
/* 요율 화면 — 위 조달청 제비율 엑셀(읽기만) · 아래 RT 표 (798-1) */
.m01-rate__bar {
flex: none;
}
.m01-rate__title {
margin-right: auto;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-rate__sheet {
flex: none;
height: clamp(260px, 44vh, 480px);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.m01-rate__rows .m01-master__grid tr[data-key] {
cursor: pointer;
}
/* 어두운 화면 — 엑셀 원본 채움(연분홍 · 연파랑 · 연초록)은 밝은 색 그대로라 그 칸 글자만 어둡게 */
.m01-rate__sheet .aislo-cell[style*="background-color"] {
color: #1f1f1f;
}
@@ -0,0 +1,103 @@
/* =============================================================================
* M01_MasterData_UI_RateSheet.ts
* 요율 화면 — 위 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 아래 = 기존 RT 표(Rows).
* RT 줄을 누르면 엑셀의 대응 칸으로 가서 칠함. 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(요율 화면을 열 때만 받음).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { fetchRateSheet, type RateSheetData } from "./M01_MasterData_Api_Fetch";
import { renderRows } from "./M01_MasterData_UI_Rows";
import type { Pick } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_RateSheet.css";
interface Handle {
select(range: string, sheet?: string): boolean;
repaint(): void;
destroy(): void;
}
/** 요율 파일이면 엑셀 + 표 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRateRows(host: HTMLElement, pick: Pick, q: string): () => void {
const sheetHost = el("div", { className: "m01-rate__sheet" });
const rowsHost = el("div", { className: "m01-rate__rows" });
const zoomIn = createButton({ label: L("M01_RateZoomIn"), variant: "ghost", size: "sm" });
const zoomOut = createButton({ label: L("M01_RateZoomOut"), variant: "ghost", size: "sm" });
const bar = el("div", {
className: "ui-toolbar m01-rate__bar",
children: [
el("span", { className: "m01-rate__title", text: L("M01_RateSheetTitle") }),
zoomOut,
zoomIn,
],
});
host.replaceChildren(bar, sheetHost, rowsHost);
const offRows = renderRows(rowsHost, pick, q);
let closed = false;
let book: RateSheetData | null = null;
let sheet: Handle | null = null;
let mark: { sheet: string; r0: number; c0: number; r1: number; c1: number } | null = null;
let parse: ((a1: string) => { r0: number; c0: number; r1: number; c1: number } | null) | null =
null;
const zoom = (dir: 1 | -1): void => {
const grid = sheetHost.querySelector<HTMLElement>(".ss-grid-host");
if (!grid) return;
const probe = new WheelEvent("wheel", { ctrlKey: true, deltaY: -120 });
const sign = wheelZoomsIn(probe) ? -1 : 1; // ☰ [설정] 휠 방향을 따라감
grid.dispatchEvent(
new WheelEvent("wheel", {
ctrlKey: true,
deltaY: sign * dir * 120,
bubbles: true,
cancelable: true,
}),
);
};
zoomIn.addEventListener("click", () => zoom(1));
zoomOut.addEventListener("click", () => zoom(-1));
/** RT 줄 눌림 → 대응 칸으로 가서 칠함 */
rowsHost.addEventListener("click", (ev) => {
const key = (ev.target as HTMLElement).closest<HTMLElement>("tr[data-key]")?.dataset.key;
if (!key || !book || !sheet || !parse) return;
const spot = book.대응[key];
const area = spot ? parse(spot.범위) : null;
if (!spot || !area) return showToast(L("M01_RateNoCell"), "info");
mark = { sheet: spot.시트, ...area };
sheet.select(spot.칸, spot.시트);
sheet.repaint();
});
void (async () => {
try {
const [data, sheetMod, addr] = await Promise.all([
fetchRateSheet(),
import("../A00_Common/spreadsheet/spreadsheet"),
import("../A00_Common/spreadsheet/spreadsheet_address"),
]);
if (closed) return;
book = data;
parse = addr.parseRange;
sheet = sheetMod.createSpreadsheet(sheetHost, data.책, {
readOnly: true,
칠: (id, r, c) =>
mark && mark.sheet === id && r >= mark.r0 && r <= mark.r1 && c >= mark.c0 && c <= mark.c1
? "danger mine"
: null,
});
} catch (error) {
if (!closed) showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
})();
return () => {
closed = true;
offRows();
sheet?.destroy();
};
}
+1
View File
@@ -154,6 +154,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
const now = flatten(cur);
const tr = el("tr");
tr.classList.toggle("is-deleted", deleted);
tr.dataset.key = key; // 요율 화면 — 줄을 누르면 엑셀 대응 칸으로(798-1)
tr.append(
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
toggleDelete(file, d.version, key),
@@ -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()
File diff suppressed because one or more lines are too long
+134
View File
@@ -0,0 +1,134 @@
"""798-1 M01 요율 제비율 내장 엑셀 — 빌더 결과 · 서식 · RT 칸 = 정본 값 · 판 바뀌면 --check 실패.
서버 · 화면 없이 도는 지킴 시험(읽기만 · 정본 JSON 을 쓰지 않음).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import openpyxl
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
import build_제비율책 as builder # noqa: E402
from M01_MasterData import M01_MasterData_RateSheet as rate_sheet # noqa: E402
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
MAIN = "26. 4. 13."
@pytest.fixture(scope="module")
def data() -> dict:
return json.loads(builder.OUT.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def xlsx():
wb = openpyxl.load_workbook(builder.XLSX_PATH)
wv = openpyxl.load_workbook(builder.XLSX_PATH, data_only=True)
return wb, wv
def _main(data: dict) -> dict:
return next(s for s in data["책"]["시트"] if s["이름"] == MAIN)
def test_book_matches_xlsx_and_rates():
"""--check 가 통과 = 저장된 책이 xlsx 를 다시 지은 것과 같고 RT 칸 값이 정본과 같음."""
assert builder.run_check() == []
def test_sheets_and_merges(data, xlsx):
wb, _ = xlsx
names = [s["이름"] for s in data["책"]["시트"]]
assert names == [ws.title for ws in wb.worksheets] and len(names) == 2
main = _main(data)
assert len(main["병합"]) == 433
assert len(data["책"]["시트"][1]["병합"]) == 7
assert data["책"]["활성"] == main["id"]
def test_format_survives(data, xlsx):
wb, _ = xlsx
book, ws = data["책"], wb[MAIN]
cells, styles = _main(data)["칸"], data["책"]["서식"]
fills = {s["채움"] for s in styles if "채움" in s}
assert fills == {"#FFEBEB", "#EBF7FF", "#EBFFEB", "#FFFF00"}
bordered = sum(1 for c in cells.values() if "테두리" in styles[c.get("서식", 0)])
src = sum(
1
for row in ws.iter_rows()
for c in row
if any(getattr(c.border, s).style for s in ("top", "bottom", "left", "right"))
)
assert bordered == src and bordered > 1300
assert any(s.get("굵게") for s in styles)
assert any(s.get("글자색") == "#FF0000" for s in styles)
assert any(s.get("형식") == "0.0" for s in styles)
assert len(_main(data)["열"]) == 189 # 잔 열 189
assert _main(data)["열"]["B"]["폭"] == pytest.approx(1.625)
assert _main(data)["행"]["2"]["높이"] == pytest.approx(62.25)
assert book["기본"]["글꼴"] == "맑은 고딕"
def test_formula_results_not_formulas(data, xlsx):
wb, wv = xlsx
cells = _main(data)["칸"]
formulas = [
c.coordinate
for row in wb[MAIN].iter_rows()
for c in row
if isinstance(c.value, str) and c.value.startswith("=")
]
assert len(formulas) == 40
for a1 in formulas:
assert "식" not in cells[a1]
assert cells[a1]["값"] == wv[MAIN][a1].value
def test_rt_cells_match_master(data):
rates = json.loads(builder.rate_check.RATE_JSON.read_text(encoding="utf-8"))
keys = {r["키"] for r in rates["줄"]}
assert set(data["대응"]) <= keys and "RT000019" not in data["대응"] # 부가세는 이 엑셀 밖
assert builder.check_values(data) == []
cells = _main(data)["칸"]
for spot in data["대응"].values():
assert cells[spot["칸"]].get("값") is not None
def test_check_fails_when_edition_changes(tmp_path, monkeypatch, data):
out = tmp_path / "책.json"
monkeypatch.setattr(builder, "OUT", out)
out.write_text(builder.dump(data), encoding="utf-8")
assert builder.run_check() == []
# 판이 바뀐 흉내 ① 책의 한 칸 값이 xlsx 와 달라짐
changed = json.loads(json.dumps(data))
_main(changed)["칸"]["BZ51"]["값"] = "(노) x 9.99"
out.write_text(builder.dump(changed), encoding="utf-8")
errors = builder.run_check()
assert any("판이 바뀜" in e for e in errors)
assert any("산재보험료" in e for e in errors) # RT000001 값 대조도 깨짐
# ② 책 파일이 없음
out.unlink()
assert "책 JSON 없음" in builder.run_check()[0]
def test_api_reads_book(data, tmp_path, monkeypatch):
assert rate_sheet.load()["대응"] == data["대응"]
monkeypatch.setattr(store, "FOLDER", tmp_path)
with pytest.raises(store.StoreError) as err:
rate_sheet.load()
assert err.value.status == 404
def test_not_picked_up_as_rate_file():
"""요율 파일 목록(`요율_*.json` 글롭)에 책이 섞이지 않음."""
names = [p.name for p in store.FOLDER.glob("요율_*.json")] + [
p.name for p in store.FOLDER.glob("요율.json")
]
assert names == ["요율_조달청제비율.json"]
+7
View File
@@ -174,4 +174,11 @@ export const ui_locales_m1 = {
B01_Dashboard_CardLogic: ["단가산출 기초", "Cost-logic library"],
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"],
M01_RateSheetTitle: [
"조달청 제비율표 — 읽기만 · 줄을 누르면 해당 칸으로",
"PPS cost-rate table — read only · click a row to jump",
],
M01_RateZoomIn: ["확대", "Zoom in"],
M01_RateZoomOut: ["축소", "Zoom out"],
M01_RateNoCell: ["이 줄은 엑셀에 대응 칸이 없음", "No matching cell in the sheet"],
} as const;