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
+7
View File
@@ -75,6 +75,12 @@ export interface SpreadsheetOptions {
/** 칸 칠(요구 621-5) — 시트 id · 행 · 열(0 부터) → 꼴 이름 `success|warning|info|danger|muted`(흐림) + `mine`(왼쪽 띠) ·
* 공백으로 여럿 · 칸 `data-tone` 으로 나감 · 보이는 칸만 부름 · 안 주면 화면 그대로 */
칠?: (시트: string, r: number, c: number) => string | null | undefined;
/** 칸 우클릭 메뉴 끝에 붙일 줄(805-1) — 시트 id · 행 · 열(0 부터) · `disabled` 줄은 흐림 · 안 주면 화면 그대로 */
cellMenu?: (
시트: string,
r: number,
c: number,
) => Array<[label: string, action: () => void, opts?: { disabled?: boolean }]>;
}
export interface SpreadsheetHandle {
@@ -144,6 +150,7 @@ export function createSpreadsheet(
book,
readOnly,
fill: opts.칠,
cellMenuExtra: opts.cellMenu && ((r, c) => opts.cellMenu!(ctx.selection.시트, r, c)),
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
@@ -113,7 +113,10 @@ export interface SpreadsheetContext {
editor: EditorHandle;
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
cellMenuExtra?: (
r: number,
c: number,
) => Array<[label: string, action: () => void, opts?: { disabled?: boolean }]>;
/** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */
dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void;
}
+29 -4
View File
@@ -1,4 +1,18 @@
/* 요율 화면 — 위 조달청 제비율 엑셀(읽기만) · 아래 RT 표 (798-1) */
/* 요율 화면 — 「시트 | 리스트」 보기 단추 · 한 번에 하나 (798-1 · 805-1) */
.m01-rate {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01-rate__sheet[hidden],
.m01-rate__rows[hidden],
.m01-rate__zoom[hidden] {
display: none;
}
.m01-rate__bar {
flex: none;
}
@@ -9,16 +23,27 @@
font-size: var(--text-caption);
}
.m01-rate__zoom {
display: inline-flex;
gap: var(--spacing-4);
}
/* 시트 보기 = 화면 높이에 맞춤 (리스트와 같이 있지 않으므로 넓게) */
.m01-rate__sheet {
flex: none;
height: clamp(260px, 44vh, 480px);
height: clamp(320px, 74vh, 900px);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.m01-rate__rows .m01-master__grid tr[data-key] {
cursor: pointer;
/* 시트에서 「리스트에서 보기」로 온 줄 — 시트 칠(danger)과 같은 색 */
.m01-rate__rows .m01-master__grid tr.is-focus td {
background: color-mix(in srgb, var(--color-danger) 14%, var(--color-canvas));
}
.m01-rate__rows .m01-master__grid tr.is-focus td:first-child {
box-shadow: inset 3px 0 var(--color-danger);
}
/* 어두운 화면 — 엑셀 원본 채움(연분홍 · 연파랑 · 연초록)은 밝은 색 그대로라 그 칸 글자만 어둡게 */
+133 -42
View File
@@ -1,16 +1,21 @@
/* =============================================================================
* M01_MasterData_UI_RateSheet.ts
* 요율 화면 — 위 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 아래 = 기존 RT 표(Rows).
* RT 줄을 누르면 엑셀의 대응 칸으로 가서 칠함. 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(요율 화면을 열 때만 받음).
* 요율 화면 — 오른쪽 위 「시트 | 리스트」 보기 단추 · 한 번에 하나만 보임(기본 시트 · 고른 보기는 세션에 기억).
* 시트 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 리스트 = 기존 RT 표(Rows).
* 오른쪽 클릭 — 리스트 줄 「시트에서 보기」 → 대응 칸으로 가서 칠함 / 시트 칸 「리스트에서 보기」 → 그 RT 줄 강조.
* 대응이 없으면 메뉴 줄이 흐림(대응 요율 없음). 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1 · 805-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(시트 보기를 처음 열 때만 받음).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createSegment } from "@ui/ui_template_segment";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { fetchRateSheet, type RateSheetData } from "./M01_MasterData_Api_Fetch";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { keyAt, type ParseArea, type RateArea } from "./M01_MasterData_UI_RateSheet_Map";
import type { Pick } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_RateSheet.css";
@@ -20,29 +25,107 @@ interface Handle {
destroy(): void;
}
/** 요율 파일이면 엑셀 + 표 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
type View = "sheet" | "list";
const VIEW_KEY = "aislo_m01_rate_view";
const savedView = (): View => (sessionStorage.getItem(VIEW_KEY) === "list" ? "list" : "sheet");
/** 요율 파일이면 시트 · 리스트 보기 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
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 menu = createMapContextMenu("m01-rate");
const zoomIn = createButton({ label: L("M01_RateZoomIn"), variant: "ghost", size: "sm" });
const zoomOut = createButton({ label: L("M01_RateZoomOut"), variant: "ghost", size: "sm" });
const zoomBox = el("span", { className: "m01-rate__zoom", children: [zoomOut, zoomIn] });
const seg = createSegment({
options: [
{ value: "sheet", label: L("M01_RateViewSheet") },
{ value: "list", label: L("M01_RateViewList") },
],
value: savedView(),
ariaLabel: L("M01_RateViewLabel"),
onChange: (v) => setView(v as View),
});
const bar = el("div", {
className: "ui-toolbar m01-rate__bar",
children: [
el("span", { className: "m01-rate__title", text: L("M01_RateSheetTitle") }),
zoomOut,
zoomIn,
zoomBox,
seg.root,
],
});
host.replaceChildren(bar, sheetHost, rowsHost);
const offRows = renderRows(rowsHost, pick, q);
const root = el("div", {
className: "m01-rate",
children: [bar, sheetHost, rowsHost, menu.element],
});
host.replaceChildren(root);
let closed = false;
let view: View = savedView();
let offRows: (() => void) | null = null;
let book: RateSheetData | null = null;
let parse: ParseArea | 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 fail = (error: unknown): void => {
if (!closed) showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
};
/** 책(대응표) + 칸 주소 읽기 — 한 번만 */
let bookP: Promise<void> | null = null;
const loadBook = (): Promise<void> =>
(bookP ??= (async () => {
const [data, addr] = await Promise.all([
fetchRateSheet(),
import("../A00_Common/spreadsheet/spreadsheet_address"),
]);
book = data;
parse = addr.parseRange;
})());
/** 내장 엑셀 만들기 — 시트 보기를 처음 열 때 한 번 */
let sheetP: Promise<void> | null = null;
const loadSheet = (): Promise<void> =>
(sheetP ??= (async () => {
const [, sheetMod] = await Promise.all([
loadBook(),
import("../A00_Common/spreadsheet/spreadsheet"),
]);
if (closed || !book) return;
sheet = sheetMod.createSpreadsheet(sheetHost, book.책, {
readOnly: true,
칠: (id, r, c) =>
mark && mark.sheet === id && r >= mark.r0 && r <= mark.r1 && c >= mark.c0 && c <= mark.c1
? "danger mine"
: null,
cellMenu: (id, r, c) => {
const key =
book && parse
? keyAt(book.대응, parse, book.책.시트.find((s) => s.id === id)?.병합, id, r, c)
: null;
return key
? [[L("M01_RateGoList"), () => setView("list", key)]]
: [[`${L("M01_RateGoList")} (${L("M01_RateNoRate")})`, () => {}, { disabled: true }]];
},
});
})());
/** 보기 바꿈 · 리스트는 보일 때마다 새로 그림(`focusKey` 줄을 찾아 강조) · 시트는 만들어 둔 것을 다시 보임 */
function setView(next: View, focusKey?: string): void {
if (next === view && !focusKey && (next === "sheet" ? sheet : offRows)) return;
view = next;
sessionStorage.setItem(VIEW_KEY, next);
seg.set(next);
sheetHost.hidden = next !== "sheet";
rowsHost.hidden = next !== "list";
zoomBox.hidden = next !== "sheet";
offRows?.();
offRows = null;
if (next === "list") offRows = renderRows(rowsHost, pick, q, focusKey);
else void loadSheet().then(() => sheet?.repaint(), fail);
}
const zoom = (dir: 1 | -1): void => {
const grid = sheetHost.querySelector<HTMLElement>(".ss-grid-host");
@@ -61,43 +144,51 @@ export function renderRateRows(host: HTMLElement, pick: Pick, q: string): () =>
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");
/** RT 줄 → 시트 보기로 바꾸고 대응 칸으로 가서 칠함 */
const showInSheet = async (key: string): Promise<void> => {
await loadSheet();
const spot = book?.대응[key];
const area: RateArea | null = spot && parse ? parse(spot.범위) : null;
if (!spot || !area || !sheet) return;
mark = { sheet: spot.시트, ...area };
sheet.select(spot.칸, spot.시트);
sheet.repaint();
setView("sheet");
requestAnimationFrame(() => {
sheet?.select(spot.칸, spot.시트);
sheet?.repaint();
});
};
/** 리스트 줄 오른쪽 클릭 */
rowsHost.addEventListener("contextmenu", (ev) => {
const key = (ev.target as HTMLElement).closest<HTMLElement>("tr[data-key]")?.dataset.key;
if (!key) return;
ev.preventDefault();
const [x, y] = [ev.clientX, ev.clientY];
void loadBook().then(() => {
if (closed) return;
const box = root.getBoundingClientRect();
menu.open(
x - box.left,
y - box.top,
book?.대응[key]
? [[L("M01_RateGoSheet"), () => void showInSheet(key).catch(fail)]]
: [
[
`${L("M01_RateGoSheet")} (${L("M01_RateNoCellShort")})`,
() => {},
{ disabled: true },
],
],
);
}, fail);
});
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");
}
})();
setView(view);
return () => {
closed = true;
offRows();
menu.close();
offRows?.();
sheet?.destroy();
};
}
@@ -0,0 +1,52 @@
/* =============================================================================
* M01_MasterData_UI_RateSheet_Map.ts
* 요율 화면 시트 칸 → RT 줄 찾기 (805-1) — 순수 계산 · 화면 · fetch 없음.
* 책의 `대응`(RT 키 → 시트 · 첫 칸 · 칠할 범위)을 거꾸로 읽음. 겹치면 가장 좁은 범위(RT000007 ⊂ RT000006).
* 병합 칸 안쪽은 병합의 왼쪽 위 칸으로 바꿔 찾음(예 고용보험 7등급 AH79:AT82).
* ========================================================================== */
export interface RateSpot {
시트: string;
칸: string;
범위: string;
}
export interface RateArea {
r0: number;
c0: number;
r1: number;
c1: number;
}
export type ParseArea = (a1: string) => RateArea | null;
const inside = (a: RateArea, r: number, c: number): boolean =>
r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1;
/** 시트 id · 행 · 열(0 부터) → RT 키 · 대응 없으면 null. `merges` = 그 시트의 병합 A1 목록. */
export function keyAt(
spots: Record<string, RateSpot>,
parse: ParseArea,
merges: readonly string[] | undefined,
sheetId: string,
row: number,
col: number,
): string | null {
let [r, c] = [row, col];
for (const text of merges ?? []) {
const m = parse(text);
if (m && inside(m, r, c)) {
[r, c] = [m.r0, m.c0];
break;
}
}
let best: string | null = null;
let bestSize = Infinity;
for (const [key, spot] of Object.entries(spots)) {
const a = spot.시트 === sheetId ? parse(spot.범위) : null;
if (!a || !inside(a, r, c)) continue;
const size = (a.r1 - a.r0 + 1) * (a.c1 - a.c0 + 1);
if (size < bestSize) [best, bestSize] = [key, size];
}
return best;
}
+30 -4
View File
@@ -91,12 +91,21 @@ const remember = (ref: string | null, name?: string): void => {
if (key && name) names.set(key, name);
};
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void {
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. `focusKey` = 그 키 줄이 있는 쪽을 찾아 강조(요율 805-1). */
export function renderRows(
host: HTMLElement,
pick: Pick,
q: string,
focusKey?: string,
): () => void {
const { sub, detail, state } = pick;
const file = pick.file.file;
let page = pick.view?.page ?? 1;
let size = guessRows(host, BAR_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
// 키 번호 순서로 놓인 파일이라 번호 ÷ 쪽 크기로 어림 — 틀리면 그린 뒤 이웃 쪽으로 찾아감
let page = focusKey
? Math.max(1, Math.ceil(Number(focusKey.replace(/\D/g, "")) / size))
: (pick.view?.page ?? 1);
let scrolled = false;
let data: RowsPage | null = null;
let unlinked = false;
@@ -154,7 +163,8 @@ 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.dataset.key = key; // 요율 화면 — 오른쪽 클릭 「시트에서 보기」 · 강조(805-1)
tr.classList.toggle("is-focus", key === focusKey);
tr.append(
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
toggleDelete(file, d.version, key),
@@ -228,9 +238,25 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
buildPager(d.total, size, d.page, go),
...empty,
);
settleFocus(d);
fit.ask();
};
/** 강조할 줄이 이 쪽에 없으면 이웃 쪽으로 · 있으면 한 번 보이는 곳으로 */
const settleFocus = (d: RowsPage): void => {
if (!focusKey || scrolled) return;
const row = host.querySelector<HTMLElement>(`tr.is-focus`);
if (row) {
scrolled = true;
row.scrollIntoView({ block: "nearest" });
return;
}
const keys = d.rows.map((r) => String(r["키"]));
if (focusKey > (keys.at(-1) ?? "") && d.page * size < d.total) go(d.page + 1);
else if (focusKey < (keys[0] ?? "") && d.page > 1) go(d.page - 1);
else scrolled = true; // 못 찾음 — 그만
};
const go = (next: number): void => {
page = next;
void load();
@@ -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
+30
View File
@@ -132,3 +132,33 @@ def test_not_picked_up_as_rate_file():
p.name for p in store.FOLDER.glob("요율.json")
]
assert names == ["요율_조달청제비율.json"]
def test_mapping_table_covers_every_rt_row(data):
"""805-1 대응표 전수 — 부가세 말고 모든 RT 줄 · 범위 안에 값 칸 있음(연금은 BR51 까지)."""
rates = json.loads(builder.rate_check.RATE_JSON.read_text(encoding="utf-8"))
assert set(data["대응"]) == {r["키"] for r in rates["줄"]} - {"RT000019"}
cells = _main(data)["칸"]
for key, spot in data["대응"].items():
first, _, last = spot["범위"].partition(":")
assert first == spot["칸"], key
in_range = [
a for a in cells if _in_range(a, spot["범위"]) and cells[a].get("값") is not None
]
assert in_range, key
assert data["대응"]["RT000004"]["범위"] == "BR47:BR51"
def _in_range(a1: str, rng: str) -> bool:
import re
def split(x: str) -> tuple[int, int]:
m = re.fullmatch(r"([A-Z]+)(\d+)", x)
col = 0
for ch in m.group(1):
col = col * 26 + ord(ch) - 64
return int(m.group(2)), col
(r0, c0), (r1, c1) = (split(p) for p in (rng.split(":") + rng.split(":"))[:2])
r, c = split(a1)
return r0 <= r <= r1 and c0 <= c <= c1
+187
View File
@@ -0,0 +1,187 @@
"""805-1 M01 요율 「시트 | 리스트」 보기 · 우클릭 이동 · 시트 ↔ RT 전수 대조.
서버 · 화면 없이 도는 지킴 시험.
① 화면 소스: 기본 보기 = 시트 · 보기 단추 둘 · 세션 기억 · 우클릭 양방향 · 흐림 줄
② 시트 칸 → RT 줄 찾기(`M01_MasterData_UI_RateSheet_Map.ts`)를 node 로 직접 돌려 대응표 전수 확인
③ 대조 스크립트(`audit_요율대조.py`): 값 다른 RT 잎 0 · 칸 없는 잎 · 시트에만 있는 칸 = 알려진 목록
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
import audit_요율대조 as audit # noqa: E402
PAGE = ROOT / "M01_MasterData/M01_MasterData_UI_RateSheet.ts"
MAP_TS = ROOT / "M01_MasterData/M01_MasterData_UI_RateSheet_Map.ts"
def _src(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_default_view_is_sheet_and_remembered():
src = _src(PAGE)
assert 'getItem(VIEW_KEY) === "list" ? "list" : "sheet"' in src # 기본 = 시트
assert "setItem(VIEW_KEY, next)" in src # 고른 보기 세션에 기억
assert 'value: "sheet", label: L("M01_RateViewSheet")' in src
assert 'value: "list", label: L("M01_RateViewList")' in src
# 한 번에 하나만 보임
assert 'sheetHost.hidden = next !== "sheet"' in src
assert 'rowsHost.hidden = next !== "list"' in src
def test_context_menus_both_ways_with_dimmed_fallback():
src = _src(PAGE)
assert 'L("M01_RateGoList")' in src and 'L("M01_RateGoSheet")' in src
assert src.count("{ disabled: true }") == 2 # 시트 칸 · 리스트 줄 둘 다 대응 없으면 흐림
assert "M01_RateNoRate" in src and "M01_RateNoCellShort" in src
# 옛 「줄 누르면 이동」은 오른쪽 클릭 메뉴로 대체
assert 'rowsHost.addEventListener("click"' not in src
assert 'rowsHost.addEventListener("contextmenu"' in src
def _node_keys(tmp: Path, cells: list[list], merges: list[str], spots: dict) -> list:
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
script = tmp / "run.mjs"
script.write_text(
f"""
import {{ readFileSync }} from "node:fs";
import {{ keyAt }} from {json.dumps(MAP_TS.as_uri())};
const cin = JSON.parse(readFileSync(process.argv[2], "utf-8"));
const parse = (a1) => {{
const [p, q = p] = a1.split(":");
const one = (s) => {{
const m = /^([A-Z]+)(\d+)$/.exec(s);
if (!m) return null;
let c = 0;
for (const ch of m[1]) c = c * 26 + ch.charCodeAt(0) - 64;
return [Number(m[2]) - 1, c - 1];
}};
const a = one(p);
const b = one(q);
if (!a || !b) return null;
const [r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0])];
const [c0, c1] = [Math.min(a[1], b[1]), Math.max(a[1], b[1])];
return {{ r0, c0, r1, c1 }};
}};
const out = cin.cells.map(([sheet, r, c]) => keyAt(cin.spots, parse, cin.merges, sheet, r, c));
console.log(JSON.stringify(out));
""",
encoding="utf-8",
)
data = tmp / "in.json"
data.write_text(
json.dumps({"cells": cells, "merges": merges, "spots": spots}), encoding="utf-8"
)
run = subprocess.run( # noqa: S603 — 고정 node 실행
[node, "--experimental-strip-types", "--no-warnings", str(script), str(data)],
capture_output=True,
text=True,
cwd=ROOT,
check=False,
)
assert run.returncode == 0, run.stderr[-800:]
return json.loads(run.stdout)
def _cells_of(a1_range: str) -> list[tuple[int, int]]:
a, _, b = a1_range.partition(":")
(ca, ra), (cb, rb) = audit.split(a), audit.split(b or a)
return [
(r - 1, c - 1)
for r in range(ra, rb + 1)
for c in range(audit.col_index(ca), audit.col_index(cb) + 1)
]
def test_every_mapped_cell_finds_its_rt_row(tmp_path):
book, sheet, rates = audit.load()
spots = book["대응"]
# 대응표 전수 — 부가세(RT000019)만 시트 밖
assert set(spots) == {r["키"] for r in rates} - {"RT000019"}
cells, expect = [], []
for key, spot in spots.items():
for r, c in _cells_of(spot["범위"]):
cells.append([spot["시트"], r, c])
expect.append(key)
got = _node_keys(tmp_path, cells, sheet["병합"], spots)
# 겹침: RT000006 범위 안의 기초액 칸(FA27:FA33)은 더 좁은 RT000007 이 가져감
for (_s, r, c), want, have in zip(cells, expect, got):
if want == "RT000006" and have == "RT000007":
assert 26 <= r <= 33 and c == audit.col_index("FA") - 1
else:
assert have == want, (audit.col_name(c + 1), r + 1, want, have)
def test_merged_inside_and_outside_cells(tmp_path):
book, sheet, _ = audit.load()
probe = {
"AI16": "RT000008", # 간접노무비 값 칸 병합(AI15:AK16) 안쪽
"AT68": "RT000002", # 고용보험 1등급 병합(AH67:AT68) 오른쪽 끝
"AT82": "RT000002", # 고용보험 7등급 병합(AH79:AT82) 아래 끝
"BR20": "RT000017", # 일반관리비 전문 병합 안쪽
"B2": None, # 머리글
"M15": None, # 규모 · 기간 글 칸
"BZ47": "RT000001", # 상자 표제 칸([산재보험료])
"BD47": "RT000005", # 상자 표제 칸([노인장기요양보험료])
"DK100": None, # 산업안전보건 주석 글(어느 상자도 아님)
"BR51": "RT000004", # 연금 2026 율
}
cells = []
for a1 in probe:
col, row = audit.split(a1)
cells.append([MAIN, row - 1, audit.col_index(col) - 1])
got = _node_keys(tmp_path, cells, sheet["병합"], book["대응"])
for (a1, want), have in zip(probe.items(), got):
assert have == want, (a1, want, have)
MAIN = audit.MAIN
def test_audit_known_gaps_only():
result = audit.audit()
leaves = result["leaves"]
assert [e for e in leaves if e["판정"] == "값 다름"] == [] # 시트와 RT 값이 다른 잎 없음
no_cell = [e for e in leaves if e["판정"] == "시트 칸 없음"]
assert {e["RT"] for e in no_cell} == {"RT000002", "RT000004", "RT000019"}
assert [e["잎"] for e in no_cell if e["RT"] == "RT000002"] == ["7등급 미만"]
assert len([e for e in no_cell if e["RT"] == "RT000004"]) == 6 # 2027 ~ 2032 (법령 부칙)
# 시트에만 있는 값 칸 — 산업안전보건관리비 「도급자관급 포함」 식의 ×1.2
assert [o["시트칸"] for o in result["orphans"]] == ["EB13"]
# B09 가 읽는 길 표시 — 자동 / 후보 글만 / 안 씀
by_key = {r["RT"]: r["B09"] for r in result["rows"]}
assert by_key["RT000008"] == "자동 셈에 씀"
assert by_key["RT000016"] == "후보 글만(값 직접 입력)"
assert by_key["RT000007"] == "안 씀"
def test_audit_b09_flags_match_rate_table():
"""대조표의 「자동 셈에 씀」 줄 = B09 `rate_table()` 이 실제로 읽는 RT 줄."""
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Rates as rates
used = {v["rt"] for v in rates.rate_table().values()}
assert used == set(audit.B09_AUTO)
def test_xlsx_writes_four_sheets(tmp_path):
import openpyxl
out = tmp_path / "list.xlsx"
audit.write_xlsx(out, audit.audit())
wb = openpyxl.load_workbook(out)
assert len(wb.sheetnames) == 4
gap = wb["RT에 있고 시트 칸 없음 · 다름"]
assert gap.max_row == 1 + 8 # 머리 + 시트 칸 없음 8 (파생은 칸 없음 아님)
+9 -3
View File
@@ -20,7 +20,7 @@ import { showConfirmDialog } from "./ui_template_modal";
* 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2).
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
export type MapContextMenuItem =
| [label: string, action: () => void]
| [label: string, action: () => void, opts?: { disabled?: boolean }]
| { label: string; children: Array<[label: string, action: () => void]> }
| "-";
@@ -103,12 +103,18 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
leaveMenu(registryEntry);
}
function buildLeaf(label: string, action: () => void, sub = false): HTMLButtonElement {
function buildLeaf(
label: string,
action: () => void,
sub = false,
disabled = false,
): HTMLButtonElement {
const entry = document.createElement("button");
entry.type = "button";
entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`;
if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘
entry.textContent = label;
entry.disabled = disabled; // 흐림 — 누르지 못함(대응 없음 등)
entry.addEventListener("click", () => {
close();
action();
@@ -209,7 +215,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
return;
}
if (Array.isArray(item)) {
element.append(buildLeaf(item[0], item[1]));
element.append(buildLeaf(item[0], item[1], false, item[2]?.disabled));
return;
}
// 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나)
+9 -3
View File
@@ -175,10 +175,16 @@ export const ui_locales_m1 = {
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"],
M01_RateSheetTitle: [
"조달청 제비율표 — 읽기만 · 줄을 누르면 해당 칸으로",
"PPS cost-rate table — read only · click a row to jump",
"조달청 제비율표 — 읽기만 · 오른쪽 클릭으로 시트 ↔ 리스트 이동",
"PPS cost-rate table — read only · right-click to jump between sheet and list",
],
M01_RateViewSheet: ["시트", "Sheet"],
M01_RateViewList: ["리스트", "List"],
M01_RateViewLabel: ["보기", "View"],
M01_RateGoSheet: ["시트에서 보기", "Show in sheet"],
M01_RateGoList: ["리스트에서 보기", "Show in list"],
M01_RateNoRate: ["대응 요율 없음", "no matching rate"],
M01_RateNoCellShort: ["대응 칸 없음", "no matching cell"],
M01_RateZoomIn: ["확대", "Zoom in"],
M01_RateZoomOut: ["축소", "Zoom out"],
M01_RateNoCell: ["이 줄은 엑셀에 대응 칸이 없음", "No matching cell in the sheet"],
} as const;