Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-09-15 19:19:49 +09:00
7 changed files with 132 additions and 33 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 {
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 MasterTable,
} 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";
const PAGE_SIZE = 50;
@@ -136,8 +143,8 @@ function buildRowsView(): RowsView {
};
let showHidden = false;
let last: MasterRows | null = null;
let requestSeq = 0;
let searchTimer = 0;
const fetchLatest = latestOnly(fetchMasterRows);
const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") });
const titleKey = el("span", { className: "z01-master__key" });
@@ -163,22 +170,27 @@ function buildRowsView(): RowsView {
children: [hiddenToggle, L("Z01_MasterData_ShowHidden")],
});
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 prev = createButton({
label: L("Z01_MasterData_Prev"),
variant: "ghost",
onClick: () => turn(-1),
});
const next = createButton({
label: L("Z01_MasterData_Next"),
variant: "ghost",
onClick: () => turn(1),
});
// 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인).
const pagerButton = (key: Parameters<typeof L>[0], onClick: () => void) =>
createButton({ label: L(key), variant: "ghost", onClick });
const first = pagerButton("Z01_MasterData_First", () => goTo(1));
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
const toolbar = el("div", {
className: "z01-master__toolbar",
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", {
className: "z01-master__panel",
children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager],
@@ -186,33 +198,34 @@ function buildRowsView(): RowsView {
toolbar.hidden = true;
pager.hidden = true;
function turn(step: number): void {
state.page += step;
function goTo(page: number): void {
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
state.page = page === Infinity ? pages : clampPage(page, pages);
void load();
}
async function load(): Promise<void> {
if (!state.file || !state.table) return;
const mine = ++requestSeq;
grid.classList.add("is-loading");
let data: MasterRows | undefined;
try {
const data = await fetchMasterRows({
data = await fetchLatest({
file: state.file.id,
table: state.table.id,
page: state.page,
size: PAGE_SIZE,
q: state.q,
});
if (mine !== requestSeq) return;
last = data;
draw();
} catch (error) {
if (mine === requestSeq) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
}
} finally {
if (mine === requestSeq) grid.classList.remove("is-loading");
grid.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
if (!data) return;
grid.classList.remove("is-loading");
last = data;
draw();
}
function draw(): void {
@@ -243,9 +256,11 @@ function buildRowsView(): RowsView {
: el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }),
);
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")}`;
prev.disabled = state.page <= 1;
next.disabled = state.page >= pages;
pageInput.value = String(state.page);
pageInput.max = String(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 {
@@ -155,6 +155,11 @@
font-size: var(--text-body-sm);
}
.z01-master__page-input {
width: 6em;
text-align: right;
}
@media (max-width: 860px) {
.z01-master__content {
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 (
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_auth import (
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_material_prices_router, dependencies=protected_with_company)
# Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01).
# ⚠ 라우터 모듈(랩탑 메인)이 아직 없음 — 없는 것을 import 하면 서버가 안 뜸.
# 올라오면 아래 두 줄 주석을 풂.
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)
+23 -1
View File
@@ -6,6 +6,8 @@
④ 쪽 수는 줄이 없어도 1
⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데
줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인)
⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감)
⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인)
TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식).
"""
@@ -22,9 +24,20 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
import {
cellText, columnTitle, pageCount, rowCells, shownColumns,
cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns,
} 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 = [
{ key: "id", label: "id", unit: null, hidden: true },
{ 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)],
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]
# columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림
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",
],
Z01_MasterData_NoRows: ["줄 없음", "No rows"],
Z01_MasterData_First: ["처음", "First"],
Z01_MasterData_Last: ["끝", "Last"],
Z01_MasterData_Prev: ["이전", "Prev"],
Z01_MasterData_Next: ["다음", "Next"],
Z01_MasterData_Page: ["쪽", "pages"],