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