Merge remote-tracking branch 'origin/dev' into sub_desktop_1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user