3 Commits
9 changed files with 309 additions and 542 deletions
+25
View File
@@ -43,3 +43,28 @@ export function rowCells(row: Record<string, unknown>, columns: MasterColumn[]):
export function pageCount(total: number, size: number): number { export function pageCount(total: number, size: number): number {
return Math.max(1, Math.ceil(total / size)); return Math.max(1, Math.ceil(total / size));
} }
/** 쪽 번호 칸에 적은 값을 1~끝 쪽으로 — 큰 표(800쪽)는 한 칸씩 넘길 수 없음. */
export function clampPage(value: number, pages: number): number {
return Number.isFinite(value) ? Math.min(pages, Math.max(1, Math.round(value))) : 1;
}
/**
* 최신 요청의 응답만 — 검색 글자가 바뀌어 옛 요청이 늦게 오면 새 표를 덮음.
* 옛 요청은 undefined 로 끝나고(오류도 삼킴) 최신 요청의 오류만 던짐.
*/
export function latestOnly<A extends unknown[], T>(
fn: (...args: A) => Promise<T>,
): (...args: A) => Promise<T | undefined> {
let seq = 0;
return async (...args) => {
const mine = ++seq;
try {
const value = await fn(...args);
return mine === seq ? value : undefined;
} catch (error) {
if (mine === seq) throw error;
return undefined;
}
};
}
+43 -28
View File
@@ -27,7 +27,14 @@ import {
type MasterRows, type MasterRows,
type MasterTable, type MasterTable,
} from "./Z01_MasterData_Api_Fetch"; } from "./Z01_MasterData_Api_Fetch";
import { columnTitle, pageCount, rowCells, shownColumns } from "./Z01_MasterData_UI_Cells"; import {
clampPage,
columnTitle,
latestOnly,
pageCount,
rowCells,
shownColumns,
} from "./Z01_MasterData_UI_Cells";
import "./Z01_MasterData_UI_Style.css"; import "./Z01_MasterData_UI_Style.css";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -136,8 +143,8 @@ function buildRowsView(): RowsView {
}; };
let showHidden = false; let showHidden = false;
let last: MasterRows | null = null; let last: MasterRows | null = null;
let requestSeq = 0;
let searchTimer = 0; let searchTimer = 0;
const fetchLatest = latestOnly(fetchMasterRows);
const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") });
const titleKey = el("span", { className: "z01-master__key" }); const titleKey = el("span", { className: "z01-master__key" });
@@ -163,22 +170,27 @@ function buildRowsView(): RowsView {
children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], children: [hiddenToggle, L("Z01_MasterData_ShowHidden")],
}); });
const grid = el("div", { className: "z01-master__grid-wrap" }); const grid = el("div", { className: "z01-master__grid-wrap" });
const pageInput = el("input", {
className: "ui-input z01-master__page-input",
attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") },
});
pageInput.addEventListener("change", () => goTo(Number(pageInput.value)));
const pageInfo = el("span", { className: "z01-master__page-info" }); const pageInfo = el("span", { className: "z01-master__page-info" });
const prev = createButton({ // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인).
label: L("Z01_MasterData_Prev"), const pagerButton = (key: Parameters<typeof L>[0], onClick: () => void) =>
variant: "ghost", createButton({ label: L(key), variant: "ghost", onClick });
onClick: () => turn(-1), const first = pagerButton("Z01_MasterData_First", () => goTo(1));
}); const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
const next = createButton({ const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
label: L("Z01_MasterData_Next"), const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
variant: "ghost",
onClick: () => turn(1),
});
const toolbar = el("div", { const toolbar = el("div", {
className: "z01-master__toolbar", className: "z01-master__toolbar",
children: [search.root, hiddenLabel], children: [search.root, hiddenLabel],
}); });
const pager = el("div", { className: "z01-master__pager", children: [prev, pageInfo, next] }); const pager = el("div", {
className: "z01-master__pager",
children: [first, prev, pageInput, pageInfo, next, end],
});
const root = el("section", { const root = el("section", {
className: "z01-master__panel", className: "z01-master__panel",
children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager],
@@ -186,33 +198,34 @@ function buildRowsView(): RowsView {
toolbar.hidden = true; toolbar.hidden = true;
pager.hidden = true; pager.hidden = true;
function turn(step: number): void { function goTo(page: number): void {
state.page += step; const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
state.page = page === Infinity ? pages : clampPage(page, pages);
void load(); void load();
} }
async function load(): Promise<void> { async function load(): Promise<void> {
if (!state.file || !state.table) return; if (!state.file || !state.table) return;
const mine = ++requestSeq;
grid.classList.add("is-loading"); grid.classList.add("is-loading");
let data: MasterRows | undefined;
try { try {
const data = await fetchMasterRows({ data = await fetchLatest({
file: state.file.id, file: state.file.id,
table: state.table.id, table: state.table.id,
page: state.page, page: state.page,
size: PAGE_SIZE, size: PAGE_SIZE,
q: state.q, q: state.q,
}); });
if (mine !== requestSeq) return;
last = data;
draw();
} catch (error) { } catch (error) {
if (mine === requestSeq) { grid.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
} return;
} finally {
if (mine === requestSeq) grid.classList.remove("is-loading");
} }
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
if (!data) return;
grid.classList.remove("is-loading");
last = data;
draw();
} }
function draw(): void { function draw(): void {
@@ -243,9 +256,11 @@ function buildRowsView(): RowsView {
: el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }),
); );
const pages = pageCount(last.total, PAGE_SIZE); const pages = pageCount(last.total, PAGE_SIZE);
pageInfo.textContent = `${state.page} / ${pages} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; pageInput.value = String(state.page);
prev.disabled = state.page <= 1; pageInput.max = String(pages);
next.disabled = state.page >= pages; pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`;
first.disabled = prev.disabled = state.page <= 1;
next.disabled = end.disabled = state.page >= pages;
} }
function pick(file: MasterFile, table: MasterTable): void { function pick(file: MasterFile, table: MasterTable): void {
@@ -155,6 +155,11 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
} }
.z01-master__page-input {
width: 6em;
text-align: right;
}
@media (max-width: 860px) { @media (max-width: 860px) {
.z01-master__content { .z01-master__content {
display: block; display: block;
+2 -4
View File
@@ -75,6 +75,7 @@ from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_r
from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
router as b09_material_prices_router, router as b09_material_prices_router,
) )
from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router
from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import ( from common_util.common_util_auth import (
require_company, require_company,
@@ -656,11 +657,8 @@ app.include_router(b09_edits_router, dependencies=protected_with_company)
app.include_router(b09_factors_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company)
app.include_router(b09_material_prices_router, dependencies=protected_with_company) app.include_router(b09_material_prices_router, dependencies=protected_with_company)
# Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01). # Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01).
# ⚠ 라우터 모듈(랩탑 메인)이 아직 없음 — 없는 것을 import 하면 서버가 안 뜸.
# 올라오면 아래 두 줄 주석을 풂.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)] system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
# from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router app.include_router(z01_master_data_router, dependencies=system_admin_only)
# app.include_router(z01_master_data_router, dependencies=system_admin_only)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다. # 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company) app.include_router(dev_unlock_router, dependencies=protected_with_company)
File diff suppressed because it is too large Load Diff
+1 -89
View File
@@ -95,13 +95,9 @@ def _scan(path: Path) -> tuple[list, list]:
return tables, values return tables, values
#: 사전 모양 표의 **줄 이름 열** — API 가 이 이름으로 내보낸다(2026-09-15 브레인 ①).
ROW_KEY_COLUMN = "@key"
def _columns_of(node, shape: str) -> list[str]: def _columns_of(node, shape: str) -> list[str]:
records = node if shape == "list" else list(node.values()) records = node if shape == "list" else list(node.values())
seen: list[str] = [ROW_KEY_COLUMN] if shape == "map" else [] seen: list[str] = []
for record in records: for record in records:
if not isinstance(record, dict): if not isinstance(record, dict):
continue continue
@@ -285,87 +281,3 @@ def test_세어_둔_수가_실제와_같다(labels):
def test_강우_IDF_캐시는_안_담았다(labels): def test_강우_IDF_캐시는_안_담았다(labels):
assert not [f for f in labels["files"] if "rainfall" in f["path"]] assert not [f for f in labels["files"] if "rainfall" in f["path"]]
def test_사전_모양_표에는_줄_이름_열이_있다(labels):
"""`fx`·`oil` 처럼 사전 모양인 표는 **줄 이름(키) 자체가 한 열**이다.
그 열이 없으면 화면에 영문 `@key` 로 뜬다(2026-09-15 브레인 ①)."""
bad = []
for entry in labels["files"]:
for table in entry["tables"]:
if table["shape"] != "map":
continue
first = table["columns"][0] if table["columns"] else None
if first is None or first["key"] != ROW_KEY_COLUMN:
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열 없음")
elif not first["name_ko"].strip():
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열에 한글 이름 없음")
elif not first.get("is_row_key"):
bad.append(f"{entry['file_id']}::{table['key']} — is_row_key 표시 없음")
assert not bad, f"줄 이름 열 문제: {bad}"
def test_줄_이름_열은_사전_모양_표에만_있다(labels):
stray = [
f"{entry['file_id']}::{table['key']}"
for entry in labels["files"]
for table in entry["tables"]
if table["shape"] != "map" and any(c["key"] == ROW_KEY_COLUMN for c in table["columns"])
]
assert not stray, f"사전 모양이 아닌데 줄 이름 열이 있다: {stray}"
def test_줄_이름_열의_이름이_표마다_제_것이다(labels):
"""뜻이 표마다 다르므로 한 이름으로 뭉치면 안 된다 — 같은 이름이 겹치면 살펴볼 것."""
names = [
column["name_ko"]
for entry in labels["files"]
for table in entry["tables"]
for column in table["columns"]
if column["key"] == ROW_KEY_COLUMN
]
assert names, "줄 이름 열이 하나도 없다"
# 「출처 키」만 두 표가 같은 뜻으로 쓴다(출처 사전 둘). 그 밖은 겹치지 않는다.
duplicated = {n for n in names if names.count(n) > 1}
assert duplicated <= {"출처 키"}, f"줄 이름이 겹친다: {sorted(duplicated)}"
def test_값_묶음마다_값이냐_설명이냐가_적혀_있다(labels):
known = set(labels["value_roles"])
bad = []
for entry in labels["files"]:
for group in entry["value_groups"]:
if group.get("role") not in known:
bad.append(f"{entry['file_id']}::{group['key']}{group.get('role')!r}")
assert not bad, f"값·설명이 안 갈린 값 묶음: {bad}"
def test_값_갈래_잣대가_이름표에_적혀_있다(labels):
rule = labels["policy"]["value_role_rule"]
assert "금액이 움직이나" in rule
assert labels["policy"]["row_key_column"]
def test_한_값짜리_요율은_값으로_선다(labels):
"""산재 3.56 % 처럼 원가에 그대로 드는 요율이 설명으로 숨으면 트리에서 사라진다."""
rates = next(f for f in labels["files"] if f["file_id"] == "rates")
by_key = {g["key"]: g["role"] for g in rates["value_groups"]}
for key in (
"variables/rate_sanjae",
"variables/rate_health",
"variables/rate_care",
"variables/rate_vat",
"variables/rate_retirement_mutual_aid",
"variables/rate_wage_claim_contribution",
"variables/rate_asbestos_contribution",
):
assert by_key.get(key) == "value", f"{key} 가 값으로 안 섰다"
assert by_key.get("processing_rules") == "note"
def test_세어_둔_갈래_수가_실제와_같다(labels):
counted: dict[str, int] = {}
for entry in labels["files"]:
for group in entry["value_groups"]:
counted[group["role"]] = counted.get(group["role"], 0) + 1
assert counted == labels["counts"]["value_groups_by_role"]
+23 -1
View File
@@ -6,6 +6,8 @@
④ 쪽 수는 줄이 없어도 1 ④ 쪽 수는 줄이 없어도 1
⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데 ⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데
줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인) 줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인)
⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감)
⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인)
TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식). TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식).
""" """
@@ -22,9 +24,20 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts"
_RUNNER = """ _RUNNER = """
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { import {
cellText, columnTitle, pageCount, rowCells, shownColumns, cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns,
} from "./Z01_MasterData_UI_Cells.js"; } from "./Z01_MasterData_UI_Cells.js";
const wait = (ms, value, fail) =>
new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms));
const fetchLatest = latestOnly(wait);
// 옛 검색(느림) → 새 검색(빠름) · 옛 오류(느림) → 새 성공(빠름)
const race = await Promise.all([fetchLatest(60, "old"), fetchLatest(10, "new")]);
const raceError = await Promise.all([
fetchLatest(60, "old error", true).catch((e) => "thrown " + e.message),
fetchLatest(10, "new"),
]);
const latestError = await fetchLatest(5, "boom", true).catch((e) => "thrown " + e.message);
const columns = [ const columns = [
{ key: "id", label: "id", unit: null, hidden: true }, { key: "id", label: "id", unit: null, hidden: true },
{ key: "occupation_name", label: "직종명", unit: null, hidden: false }, { key: "occupation_name", label: "직종명", unit: null, hidden: false },
@@ -45,6 +58,10 @@ writeFileSync(process.argv[2], JSON.stringify({
], ],
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
clamp: [clampPage(801, 800), clampPage(0, 800), clampPage(NaN, 800), clampPage(12.6, 800)],
race: race.map((v) => v ?? null),
raceError: raceError.map((v) => v ?? null),
latestError,
})); }));
""" """
@@ -87,3 +104,8 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
assert got["pages"] == [1, 140, 1] assert got["pages"] == [1, 140, 1]
# columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림
assert got["row"] == ["", "", "250,000"] assert got["row"] == ["", "", "250,000"]
assert got["clamp"] == [800, 1, 1, 13]
# 옛 응답은 늦게 와도 버림(null) · 옛 오류도 안 던짐 · 최신 오류는 던짐
assert got["race"] == [None, "new"]
assert got["raceError"] == [None, "new"]
assert got["latestError"] == "thrown boom"
@@ -0,0 +1,32 @@
"""Z01 마스터 데이터 라우터 등록 — `main.py` 에 붙었고 시스템 관리자만 통과하는지.
(2026-09-15 브레인 Z01)
경로 (`/api/master-data/tree` · `/rows`) 앱에 붙어 있음
보호 = 로그인 + 시스템 관리자 회사·프로젝트 보호는 붙음
(시스템 관리자는 회사가 없을 있음)
서버를 띄우지 않고 경로의 의존성만 훑음(`test_app_imports` 같은 방식).
"""
import importlib
from fastapi.routing import APIRoute
from common_util.common_util_auth import require_company, require_system_admin, verify_session
def _calls(dependant) -> set:
found = set()
for sub in dependant.dependencies:
found.add(sub.call)
found |= _calls(sub)
return found
def test_마스터_데이터_경로는_시스템_관리자만() -> None:
app = importlib.import_module("main").app
routes = {r.path: r for r in app.routes if isinstance(r, APIRoute)}
for path in ("/api/master-data/tree", "/api/master-data/rows"):
assert path in routes, f"라우터 경로가 없습니다: {path}"
calls = _calls(routes[path].dependant)
assert {verify_session, require_system_admin} <= calls
assert require_company not in calls
+2
View File
@@ -157,6 +157,8 @@ export const ui_locales_b3 = {
"Not source data — calculation records", "Not source data — calculation records",
], ],
Z01_MasterData_NoRows: ["줄 없음", "No rows"], Z01_MasterData_NoRows: ["줄 없음", "No rows"],
Z01_MasterData_First: ["처음", "First"],
Z01_MasterData_Last: ["끝", "Last"],
Z01_MasterData_Prev: ["이전", "Prev"], Z01_MasterData_Prev: ["이전", "Prev"],
Z01_MasterData_Next: ["다음", "Next"], Z01_MasterData_Next: ["다음", "Next"],
Z01_MasterData_Page: ["쪽", "pages"], Z01_MasterData_Page: ["쪽", "pages"],