From 7150fd8289d87a58fb7f57bb71adf6825a36fd16 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 18:56:10 +0900 Subject: [PATCH 01/48] =?UTF-8?q?feat(z01=C2=B7b01):=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=E3=80=8C?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=E3=80=8D=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=98=20[=EB=A7=88=EC=8A=A4=ED=84=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0]=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC(=EA=B0=88=EB=9E=98=E2=86=92=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=E2=86=92=ED=91=9C)=20/=20=EC=9A=B0=20=ED=91=9C=20?= =?UTF-8?q?=EC=A4=84(=EC=AA=BD=20=EB=82=98=EB=88=84=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B2=80=EC=83=89)=20=C2=B7=20=ED=95=9C=EA=B8=80=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=80=20API=20label=20=EA=B7=B8=EB=8C=80=EB=A1=9C(?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=82=AC=EC=A0=84=20=EC=97=86=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EB=B6=99=EC=9D=80=20=EC=97=B4?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=20=C2=B7=20hidden=20=EC=97=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=88=A8=EA=B9=80=20=C2=B7=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20Z01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- A00_Common/main.ts | 1 + A00_Common/router.ts | 2 + B01_Dashboard/B01_Dashboard_UI_Page.ts | 22 +- B01_Dashboard/B01_Dashboard_UI_Style.css | 11 +- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 63 +++++ Z01_MasterData/Z01_MasterData_UI_Cells.ts | 38 +++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 266 +++++++++++++++++++++ Z01_MasterData/Z01_MasterData_UI_Style.css | 167 +++++++++++++ config/config_frontend.ts | 3 + resources/tester/test_z01_master_cells.py | 82 +++++++ tsconfig.json | 2 +- ui_template/ui_template_locale_b3.ts | 25 ++ 12 files changed, 679 insertions(+), 3 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_Api_Fetch.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_Cells.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_Page.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_Style.css create mode 100644 resources/tester/test_z01_master_cells.py diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 8f218420..26d914b8 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -26,6 +26,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [ ROUTES.B09_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.Z01_MASTER_DATA, ]; function bootstrap(): void { diff --git a/A00_Common/router.ts b/A00_Common/router.ts index a3c56318..b6c99652 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -59,6 +59,8 @@ const routeTable: Partial Promise>> = { (await import("../B11_Status/B11_Status_UI_Page")).renderB11Status, [ROUTES.B11_LOADING]: async () => (await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading, + [ROUTES.Z01_MASTER_DATA]: async () => + (await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData, }; /** 로그인 여부 (토큰 존재 확인) */ diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 6b445015..9f675b88 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -130,6 +130,19 @@ async function loadRoleData(state: DashboardState): Promise { } } +function buildSystemSettingsPanel(): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-dashboard__actions"; + wrap.append( + createButton({ + label: L("B01_Dashboard_MasterData"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA), + }), + ); + return wrap; +} + function buildPage(state: DashboardState): HTMLElement { // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); @@ -138,8 +151,15 @@ function buildPage(state: DashboardState): HTMLElement { grid.className = "b01-dashboard__grid"; if (state.user.role === "SYSTEM_ADMIN") { + // 리소스 현황 오른쪽에 시스템 설정 — 앞으로 다른 설정 단추도 이 칸에 듦 (2026-09-15 브레인 Z01). + const topRow = document.createElement("div"); + topRow.className = "b01-dashboard__top-row"; + topRow.append( + section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources)), + section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel()), + ); grid.append( - section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), + topRow, section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 35f94502..ceab81c6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -31,6 +31,14 @@ grid-column: 1 / -1; } +/* 리소스 현황 | 시스템 설정 — 설정 칸은 단추만 들어 좁게. */ +.b01-dashboard__top-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); + gap: var(--spacing-24); +} + .b01-dashboard__actions { display: flex; flex-wrap: wrap; @@ -280,7 +288,8 @@ @media (max-width: 860px) { .b01-dashboard__header, - .b01-dashboard__grid { + .b01-dashboard__grid, + .b01-dashboard__top-row { display: block; } diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts new file mode 100644 index 00000000..ca287649 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * Z01_MasterData_Api_Fetch.ts + * 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만 + * + * 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { MasterColumn } from "./Z01_MasterData_UI_Cells"; + +export interface MasterTable { + id: string; + label: string; + key: string; + row_count: number; +} + +export interface MasterFile { + id: string; + label: string; + key: string; + tables: MasterTable[]; +} + +export interface MasterGroup { + key: "logic" | "base" | "byproduct" | "seed"; + label: string; + files: MasterFile[]; +} + +export interface MasterRows { + columns: MasterColumn[]; + rows: Record[]; + total: number; +} + +async function request(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + const data = (await response.json()) as { detail?: string } & T; + if (!response.ok) throw new Error(data.detail ?? "Request failed"); + return data; +} + +export async function fetchMasterTree(): Promise { + return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups; +} + +export function fetchMasterRows(query: { + file: string; + table: string; + page: number; + size: number; + q: string; +}): Promise { + const params = new URLSearchParams({ + file: query.file, + table: query.table, + page: String(query.page), + size: String(query.size), + q: query.q, + }); + return request(`/master-data/rows?${params}`); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts new file mode 100644 index 00000000..7669fe5e --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * Z01_MasterData_UI_Cells.ts + * 마스터 표 칸 글자 — 숨김 열 · 열 제목 · 칸 값 · 쪽 수 (import 없음 · 시험이 그대로 돌림) + * + * 한글 이름은 API 가 `label` 로 줌(이름표 파일 `resources/data_master_labels/`) — 화면은 + * 사전을 두지 않고 받은 대로 보임. 두 벌이 되면 어긋나기 때문(2026-09-15 브레인 Z01 ①). + * ========================================================================== */ + +export interface MasterColumn { + key: string; + label: string; + unit?: string | null; + /** 내부 id · sha · 생성시각 — 기본으로 안 보임 */ + hidden?: boolean; +} + +export function shownColumns(columns: MasterColumn[], showHidden: boolean): MasterColumn[] { + return showHidden ? columns : columns.filter((column) => !column.hidden); +} + +export function columnTitle(column: MasterColumn): string { + return column.unit ? `${column.label} (${column.unit})` : column.label; +} + +/** 단위가 붙은 수만 쉼표 — 연도·코드 같은 수(단위 없음)에 쉼표가 끼면 틀린 값처럼 보임. */ +export function cellText(value: unknown, unit?: string | null): string { + if (value === null || value === undefined) return ""; + if (typeof value === "boolean") return value ? "예" : "아니오"; + if (typeof value === "number" && unit) { + return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }); + } + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function pageCount(total: number, size: number): number { + return Math.max(1, Math.ceil(total / size)); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts new file mode 100644 index 00000000..7d8fd84c --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -0,0 +1,266 @@ +/* ============================================================================= + * Z01_MasterData_UI_Page.ts + * 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색) + * + * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터 + * (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립). + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { + createButton, + createInputField, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { t as L } from "@ui/ui_template_locale"; +import { navigateTo } from "../A00_Common/router"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + fetchMasterRows, + fetchMasterTree, + type MasterFile, + type MasterGroup, + type MasterRows, + type MasterTable, +} from "./Z01_MasterData_Api_Fetch"; +import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells"; +import "./Z01_MasterData_UI_Style.css"; + +const PAGE_SIZE = 50; +const SEARCH_DELAY_MS = 300; + +export async function renderZ01MasterData(root: HTMLElement): Promise { + const user = await fetchSessionUser().catch(() => null); + if (user?.role !== "SYSTEM_ADMIN") { + showToast(L("Z01_MasterData_AdminOnly"), "error"); + navigateTo(ROUTES.B01_ACCOUNT); + return; + } + showLoadingOverlay(); + let groups: MasterGroup[] = []; + try { + groups = await fetchMasterTree(); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + } finally { + hideLoadingOverlay(); + } + root.innerHTML = ""; + root.append(buildPage(groups)); +} + +function buildPage(groups: MasterGroup[]): HTMLElement { + const view = buildRowsView(); + const tree = buildTree(groups, view.pick); + const content = el("div", { className: "z01-master__content", children: [tree, view.root] }); + return createGeneralLayout({ + pageClass: "z01-master", + title: L("Z01_MasterData_Title"), + subtitle: L("Z01_MasterData_Subtitle"), + content, + }).root; +} + +function buildTree( + groups: MasterGroup[], + pick: (file: MasterFile, table: MasterTable) => void, +): HTMLElement { + const nav = el("nav", { className: "z01-master__tree" }); + for (const group of groups) { + const groupBox = folder("z01-master__group", group.label, String(group.files.length)); + groupBox.open = true; + // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). + if (group.key === "byproduct") { + groupBox.append( + el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), + ); + } + for (const file of group.files) { + const fileBox = folder( + "z01-master__file", + file.label, + file.label === file.key ? "" : file.key, + ); + for (const table of file.tables) { + const button = el("button", { + className: "z01-master__table", + attrs: { type: "button", title: table.key }, + children: [ + el("span", { text: table.label }), + el("span", { + className: "z01-master__key", + text: table.row_count.toLocaleString("ko-KR"), + }), + ], + }); + button.addEventListener("click", () => { + nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active"); + button.classList.add("is-active"); + pick(file, table); + }); + fileBox.append(button); + } + groupBox.append(fileBox); + } + nav.append(groupBox); + } + return nav; +} + +/** 접는 칸 — 브라우저 기본
로 둠. */ +function folder(className: string, label: string, aside: string): HTMLDetailsElement { + const summary = el("summary", { + children: [ + el("span", { text: label }), + el("span", { className: "z01-master__key", text: aside }), + ], + }); + return el("details", { className, children: [summary] }); +} + +interface RowsView { + root: HTMLElement; + pick: (file: MasterFile, table: MasterTable) => void; +} + +function buildRowsView(): RowsView { + const state = { + file: null as MasterFile | null, + table: null as MasterTable | null, + page: 1, + q: "", + }; + let showHidden = false; + let last: MasterRows | null = null; + let requestSeq = 0; + let searchTimer = 0; + + const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); + const titleKey = el("span", { className: "z01-master__key" }); + const search = createInputField({ + type: "search", + placeholder: L("Z01_MasterData_Search"), + onInput: (value) => { + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + state.q = value.trim(); + state.page = 1; + void load(); + }, SEARCH_DELAY_MS); + }, + }); + const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); + hiddenToggle.addEventListener("change", () => { + showHidden = hiddenToggle.checked; + draw(); + }); + const hiddenLabel = el("label", { + className: "z01-master__check", + children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], + }); + const grid = el("div", { className: "z01-master__grid-wrap" }); + 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), + }); + 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 root = el("section", { + className: "z01-master__panel", + children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], + }); + toolbar.hidden = true; + pager.hidden = true; + + function turn(step: number): void { + state.page += step; + void load(); + } + + async function load(): Promise { + if (!state.file || !state.table) return; + const mine = ++requestSeq; + grid.classList.add("is-loading"); + try { + const data = await fetchMasterRows({ + 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"); + } + } + + function draw(): void { + if (!last) return; + const columns = shownColumns(last.columns, showHidden); + const headRow = el("tr"); + for (const column of columns) { + const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); + if (column.label !== column.key) { + th.append(el("span", { className: "z01-master__key", text: column.key })); + } + headRow.append(th); + } + const body = el("tbody"); + for (const row of last.rows) { + const tr = el("tr"); + for (const column of columns) { + const text = cellText(row[column.key], column.unit); + tr.append(el("td", { text, attrs: { title: text } })); + } + body.append(tr); + } + grid.replaceChildren( + last.rows.length + ? el("table", { + className: "z01-master__grid", + children: [el("thead", { children: [headRow] }), body], + }) + : 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; + } + + function pick(file: MasterFile, table: MasterTable): void { + state.file = file; + state.table = table; + state.page = 1; + title.textContent = `${file.label} › ${table.label}`; + titleKey.textContent = `${file.key} / ${table.key}`; + toolbar.hidden = false; + pager.hidden = false; + last = null; + grid.replaceChildren(); + void load(); + } + + return { root, pick }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css new file mode 100644 index 00000000..7a37171a --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -0,0 +1,167 @@ +/* Z01 마스터 데이터 — 좌 트리 / 우 표 */ +.z01-master [hidden] { + display: none !important; +} + +.z01-master__content { + display: grid; + grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); + gap: var(--spacing-24); + align-items: start; +} + +.z01-master__tree { + position: sticky; + top: var(--spacing-16); + max-height: calc(100vh - 160px); + overflow-y: auto; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + font-size: var(--text-body-sm); +} + +.z01-master__tree summary { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + cursor: pointer; + color: var(--color-text-body); +} + +.z01-master__group > summary { + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.z01-master__file { + margin-left: var(--spacing-12); +} + +.z01-master__key { + margin-left: var(--spacing-4); + color: var(--color-text-muted); + font-size: var(--text-caption); + font-weight: var(--font-weight-regular); +} + +.z01-master__note { + margin: 0 var(--spacing-8) var(--spacing-4); + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.z01-master__table { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-4) var(--spacing-8) var(--spacing-4) var(--spacing-24); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-body); + font: inherit; + text-align: left; + cursor: pointer; +} + +.z01-master__table:hover { + background: var(--color-surface-raised); +} + +.z01-master__table.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.z01-master__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + min-width: 0; +} + +.z01-master__title { + display: inline; + color: var(--color-text); + font-size: var(--text-body); +} + +.z01-master__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-16); +} + +.z01-master__check { + display: flex; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.z01-master__grid-wrap { + max-height: calc(100vh - 300px); + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} + +.z01-master__grid-wrap.is-loading { + opacity: 0.5; +} + +.z01-master__grid { + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.z01-master__grid th, +.z01-master__grid td { + max-width: 320px; + padding: var(--spacing-8) var(--spacing-12); + overflow: hidden; + border-bottom: 1px solid var(--color-border); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.z01-master__grid th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.z01-master__grid th .z01-master__key { + display: block; + margin-left: 0; +} + +.z01-master__pager { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +@media (max-width: 860px) { + .z01-master__content { + display: block; + } + + .z01-master__tree { + position: static; + margin-bottom: var(--spacing-24); + } +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index bdd275a1..277a9238 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -116,6 +116,8 @@ export const ROUTES = { B11_STATUS: "b11-status", // 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면. B11_LOADING: "b11-loading", + // 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01). + Z01_MASTER_DATA: "z01-master-data", } as const; export type RouteKey = keyof typeof ROUTES; @@ -138,6 +140,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, ROUTES.B11_LOADING, + ROUTES.Z01_MASTER_DATA, ]; /* ----------------------------------------------------------------------------- diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py new file mode 100644 index 00000000..82c485e1 --- /dev/null +++ b/resources/tester/test_z01_master_cells.py @@ -0,0 +1,82 @@ +"""Z01 마스터 데이터 표 칸 — 숨김 열 · 열 제목(한글 label + 단위) · 칸 글자 · 쪽 수 (2026-09-15 브레인 Z01). + + ① 한글 이름은 API 가 `label` 로 줌 — 화면은 사전을 안 가짐(받은 대로 보임) + ② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임 + ③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로 + ④ 쪽 수는 줄이 없어도 1 +TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells.js"; + +const columns = [ + { key: "id", label: "id", unit: null, hidden: true }, + { key: "occupation_name", label: "직종명", unit: null, hidden: false }, + { key: "daily_wage_krw", label: "일 노임", unit: "원", hidden: false }, +]; +writeFileSync(process.argv[2], JSON.stringify({ + shown: shownColumns(columns, false).map((c) => c.key), + shownAll: shownColumns(columns, true).map((c) => c.key), + titles: columns.map(columnTitle), + cells: [ + cellText(null, null), + cellText(250000, "원"), + cellText(1.23456, "㎥"), + cellText(2026, null), + cellText({ a: 1 }, null), + cellText(true, null), + cellText("보통인부", null), + ], + pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], +})); +""" + + +def test_칸_글자와_숨김_열(tmp_path: Path) -> None: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(SOURCE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--ignoreConfig", + "--noCheck", + "--noResolve", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "output.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + got = json.loads(result.read_text(encoding="utf-8")) + + assert got["shown"] == ["occupation_name", "daily_wage_krw"] + assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"] + assert got["titles"] == ["id", "직종명", "일 노임 (원)"] + assert got["cells"] == ["", "250,000", "1.23456", "2026", '{"a":1}', "예", "보통인부"] + assert got["pages"] == [1, 140, 1] diff --git a/tsconfig.json b/tsconfig.json index 3c703b96..4b9da6ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,6 @@ "maplibre-gl": ["config/node_modules/maplibre-gl"], } }, - "include": ["A00_Common", "A0*", "B0*", "B1*", "ui_template", "config/**/*", "common_util"], + "include": ["A00_Common", "A0*", "B0*", "B1*", "Z0*", "ui_template", "config/**/*", "common_util"], "exclude": ["0_old", "node_modules", "venv", "dist"] } diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index f5bd2680..67183d6a 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -136,4 +136,29 @@ export const ui_locales_b3 = { "법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)", "Only the stations required by the forest act table 2 are automatic — grade > 8% sandy/clay soil (our preset has one soil class) and ≤ 8% soft/wet ranges you enter. Manual 3-2 covers all non-concrete stations. Suggested thickness 0.10 (manual) · C·L from practice (no gravel row in the standard table)", ], + /* --- B01 시스템 설정 · Z01 마스터 데이터 (2026-09-15 브레인 Z01) --- */ + B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"], + B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], + Z01_MasterData_Title: ["마스터 데이터", "Master Data"], + Z01_MasterData_Subtitle: [ + "로직·기초값을 표로 봄 — 읽기 전용", + "Browse logic and base values — read only", + ], + Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], + Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], + Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"], + Z01_MasterData_Search: ["검색", "Search"], + Z01_MasterData_ShowHidden: [ + "숨긴 열 보기(내부 id · sha · 생성시각)", + "Show hidden columns (id · sha · created)", + ], + Z01_MasterData_Byproduct: [ + "정본 아님 — 계산이 남긴 기록", + "Not source data — calculation records", + ], + Z01_MasterData_NoRows: ["줄 없음", "No rows"], + Z01_MasterData_Prev: ["이전", "Prev"], + Z01_MasterData_Next: ["다음", "Next"], + Z01_MasterData_Page: ["쪽", "pages"], + Z01_MasterData_Rows: ["줄", "rows"], } as const; From 844f1a97002ae21103f4674dfed54aea378be551 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 18:56:33 +0900 Subject: [PATCH 02/48] =?UTF-8?q?feat(z01):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20145=EC=B9=B8=20=ED=95=9C=EA=B8=80=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=2034=20=C2=B7=20=ED=91=9C=2091=20=C2=B7=20=EC=97=B4?= =?UTF-8?q?=20447=20=C2=B7=20=EA=B0=92=20=EB=AC=B6=EC=9D=8C=20172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resources/data_master_labels/labels_2026-01-01.json` — 이름만 담고 값은 안 담음(고쳐도 계산 안 바뀜) - 갈래는 브레인 나눔표 그대로 — 로직 17 · 기초값 9 · 부산물 4 · 씨앗 1(`library_structure/masonry_wet`), 폴더 장부 `_manifest` 셋은 31 밖이라 부산물 · 강우 IDF 캐시 96 은 뺌 - 찾는 차례 `column_overrides["파일id/열key"]` → `columns["열key"]` → 영문 키 그대로 - `unit` 은 이름표에서만 정함(자료에서 지어내지 않음) · 내부 id·지문·원문 줄번호 14칸은 숨김 표시 - 뜻 못 밝힌 열 0 — 못 밝히면 이름을 비우고 `unknown` 에 사유를 적는 자리를 둠 - 새 시험 `test_master_labels_cover.py` 17건 — 마스터에 있는데 이름표에 없는 표·열, 반대로 묵은 이름표, 갈래 수, 세어 둔 수까지 대조(마스터가 늘면 빨강으로 알림) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 6404 +++++++++++++++++ resources/tester/test_master_labels_cover.py | 273 + 2 files changed, 6677 insertions(+) create mode 100644 resources/data_master_labels/labels_2026-01-01.json create mode 100644 resources/tester/test_master_labels_cover.py diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json new file mode 100644 index 00000000..6875d5b8 --- /dev/null +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -0,0 +1,6404 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_master_labels", + "effective_date": "2026-01-01", + "generated_at": "2026-09-15T18:55:59+09:00", + "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", + "policy": { + "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", + "no_invented_meaning": "뜻을 모르는 열은 name_ko 를 비우고 unknown 에 사유를 적는다. 지어내지 않는다.", + "unit_is_label_only": "unit 은 이름표에서만 정한다. 자료에 없는 단위를 만들어 붙이지 않는다.", + "counting_rule": "표 = 화면이 표로 그릴 마디(줄이 여럿인 마디). 값 묶음 = 표가 아닌 마디(설명·방침·한 값짜리 요율 따위).", + "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", + "out_of_31": "폴더 장부(_manifest.json) 셋은 브레인 31 밖이라 부산물로 둔다.", + "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다." + }, + "lookup_order": { + "column": [ + "column_overrides['/<열key>']", + "columns['<열key>']", + "영문 키 그대로" + ], + "value_group": [ + "value_overrides['::']", + "value_keys['<마지막 조각>']", + "영문 키 그대로" + ] + }, + "kinds": { + "logic": { + "name_ko": "로직", + "summary": "규칙과 표 — 법령·품셈이 정해 둔 것. 값이 바뀌는 자리가 아니라 셈이 굴러가는 방식이다." + }, + "base_value": { + "name_ko": "기초값", + "summary": "때가 되면 바뀌는 값 — 노임·자재·기계·시세·요율. 판을 갈아 끼우는 자리다." + }, + "byproduct": { + "name_ko": "부산물", + "summary": "다른 벌에서 나온 기록. ⚠ 정본이 아니다 — 고쳐도 정본이 안 바뀐다." + }, + "seed": { + "name_ko": "씨앗", + "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." + } + }, + "counts": { + "files": 34, + "tables": 91, + "columns": 447, + "value_groups": 172, + "unknown": 0 + }, + "files": [ + { + "file_id": "pum_forest", + "path": "resources/data_cost_input_value/pum_forest_2026.json", + "name_ko": "산림사업 표준품셈 표 원문", + "summary": "산림청고시 제2025-82호의 표를 줄·칸 그대로 옮긴 벌. 모든 품과 소요량의 뿌리다.", + "kind": "logic", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "읽어 들인 산림품셈 원문 파일.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/pum/tables", + "name_ko": "산림품셈 표", + "summary": "표 하나가 한 줄. 머리와 줄을 원문 그대로 담는다.", + "shape": "list", + "rows": 476, + "columns": [ + { + "key": "table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "rows", + "name_ko": "표 줄", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "variables/pum/normalization_status", + "name_ko": "정돈 상태", + "summary": "자원 코드 정돈이 끝났는지." + }, + { + "key": "variables/pum/representation", + "name_ko": "담은 꼴", + "summary": "원문 표를 어떤 꼴로 담았는지." + } + ] + }, + { + "file_id": "pum_const", + "path": "resources/data_cost_input_value/pum_const_2026.json", + "name_ko": "건설공사 표준품셈 표 원문", + "summary": "산림품셈에 없는 공종을 메우는 보완 벌. 산림품셈이 1차, 이쪽이 2차다.", + "kind": "logic", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "읽어 들인 건설품셈 원문 파일 목록.", + "shape": "list", + "rows": 45, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/pum/tables", + "name_ko": "건설품셈 표", + "summary": "표 하나가 한 줄. 머리와 줄을 원문 그대로 담는다.", + "shape": "list", + "rows": 2192, + "columns": [ + { + "key": "table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "rows", + "name_ko": "표 줄", + "unit": "", + "visible": true + }, + { + "key": "source_file", + "name_ko": "원문 파일", + "unit": "", + "visible": false + } + ] + } + ], + "value_groups": [ + { + "key": "variables/pum/normalization_status", + "name_ko": "정돈 상태", + "summary": "자원 코드 정돈이 끝났는지." + }, + { + "key": "variables/pum/representation", + "name_ko": "담은 꼴", + "summary": "원문 표를 어떤 꼴로 담았는지." + } + ] + }, + { + "file_id": "work_item_master", + "path": "resources/data_work_item_master/work_item_master_2026-01-01.json", + "name_ko": "공종 마스터", + "summary": "품셈 목차를 공종코드(FP-…) 나무로 세운 벌. 수량 줄이 붙는 자리다.", + "kind": "logic", + "tables": [ + { + "key": "orphan_tables", + "name_ko": "공종에 못 붙인 표", + "summary": "목차 어디에도 못 붙인 품셈 표.", + "shape": "list", + "rows": 17, + "columns": [ + { + "key": "pum_table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + } + ] + }, + { + "key": "work_items", + "name_ko": "공종", + "summary": "공종코드·이름·단계와 딸린 품셈 표. 마스터의 뼈대다.", + "shape": "list", + "rows": 477, + "columns": [ + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "number", + "name_ko": "품셈 번호", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "이름", + "unit": "", + "visible": true + }, + { + "key": "level", + "name_ko": "단계", + "unit": "", + "visible": true + }, + { + "key": "parent_code", + "name_ko": "상위 공종코드", + "unit": "", + "visible": true + }, + { + "key": "sort_order", + "name_ko": "정렬 차례", + "unit": "", + "visible": false + }, + { + "key": "tables", + "name_ko": "딸린 품셈 표", + "unit": "", + "visible": true + }, + { + "key": "parent_mode", + "name_ko": "하위 고르는 방식", + "unit": "", + "visible": true + }, + { + "key": "variant_keys", + "name_ko": "갈래 키", + "unit": "", + "visible": true + }, + { + "key": "steps", + "name_ko": "단계 합산 구성", + "unit": "", + "visible": true + }, + { + "key": "steps_basis", + "name_ko": "단계 합산 근거", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "stats", + "name_ko": "집계", + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + } + ] + }, + { + "file_id": "coef", + "path": "resources/data_cost_input_value/coef_2026.json", + "name_ko": "품셈 계수 — 할증·토량환산", + "summary": "재료·노무 할증표, 토량환산계수 L·C, 공구손료율.", + "kind": "logic", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/coef_soil_C/records", + "name_ko": "토량환산계수 C", + "summary": "다진 흙이 줄어드는 비(다져진 상태 ÷ 자연 상태). 토질마다 하한·상한.", + "shape": "list", + "rows": 17, + "columns": [ + { + "key": "soil_type", + "name_ko": "토질", + "unit": "", + "visible": true + }, + { + "key": "min", + "name_ko": "계수 하한", + "unit": "", + "visible": true + }, + { + "key": "max", + "name_ko": "계수 상한", + "unit": "", + "visible": true + }, + { + "key": "rule", + "name_ko": "별도 규정", + "unit": "", + "visible": true + }, + { + "key": "duplicate_group", + "name_ko": "원문 중복 묶음", + "unit": "", + "visible": true + }, + { + "key": "duplicate_index", + "name_ko": "중복 차례", + "unit": "", + "visible": true + }, + { + "key": "selection", + "name_ko": "채택 여부", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/coef_soil_L/records", + "name_ko": "토량환산계수 L", + "summary": "판 흙이 부푸는 비(흐트러진 상태 ÷ 자연 상태). 토질마다 하한·상한.", + "shape": "list", + "rows": 17, + "columns": [ + { + "key": "soil_type", + "name_ko": "토질", + "unit": "", + "visible": true + }, + { + "key": "min", + "name_ko": "계수 하한", + "unit": "", + "visible": true + }, + { + "key": "max", + "name_ko": "계수 상한", + "unit": "", + "visible": true + }, + { + "key": "duplicate_group", + "name_ko": "원문 중복 묶음", + "unit": "", + "visible": true + }, + { + "key": "duplicate_index", + "name_ko": "중복 차례", + "unit": "", + "visible": true + }, + { + "key": "selection", + "name_ko": "채택 여부", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/surcharge_labor/tables", + "name_ko": "노무 할증표 원문", + "summary": "작업시기·경과연수·집단화정도처럼 품을 올리고 내리는 표를 원문 그대로 실은 벌.", + "shape": "list", + "rows": 26, + "columns": [ + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "rows", + "name_ko": "표 줄", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/surcharge_mat/tables", + "name_ko": "재료 할증표 원문", + "summary": "품셈 1-3-1 재료의 할증을 원문 표째로 실은 벌.", + "shape": "list", + "rows": 5, + "columns": [ + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "rows", + "name_ko": "표 줄", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "variables/coef_soil_C/duplicate_note", + "name_ko": "원문 중복 비고", + "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리." + }, + { + "key": "variables/coef_soil_C/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/coef_soil_L/duplicate_note", + "name_ko": "원문 중복 비고", + "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리." + }, + { + "key": "variables/coef_soil_L/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/rate_tool", + "name_ko": "공구손료율", + "summary": "주요 재료비에 곱하는 공구손료 율(하한·상한). 어느 값을 쓸지는 고름이 필요하다." + }, + { + "key": "variables/surcharge_labor/application_status", + "name_ko": "적용 상태", + "summary": "쓰려면 무엇이 더 정해져야 하는지." + }, + { + "key": "variables/surcharge_labor/representation", + "name_ko": "담은 꼴", + "summary": "원문 표를 어떤 꼴로 담았는지." + }, + { + "key": "variables/surcharge_labor/unit", + "name_ko": "값 단위", + "summary": "할증은 %다." + }, + { + "key": "variables/surcharge_mat/duplicate_application_prohibited", + "name_ko": "중복 적용 금지", + "summary": "두 자리에 걸면 두 번 세게 된다는 표시." + }, + { + "key": "variables/surcharge_mat/representation", + "name_ko": "담은 꼴", + "summary": "원문 표를 어떤 꼴로 담았는지." + }, + { + "key": "variables/surcharge_mat/unit", + "name_ko": "값 단위", + "summary": "할증은 %다." + } + ] + }, + { + "file_id": "material_surcharge", + "path": "resources/data_material_surcharge/material_surcharge_2026-01-01.json", + "name_ko": "재료 할증률", + "summary": "자재마다 몇 %를 더 사는지. 산림품셈 1-3-1 이 정본이고 건설품셈은 보완이다.", + "kind": "logic", + "tables": [ + { + "key": "candidates_pending_user/items", + "name_ko": "사용자 확정 대기 후보", + "summary": "원문에 이름은 있으나 쓰임이 달라 아직 안 쓰는 줄.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "material", + "name_ko": "자재명", + "unit": "", + "visible": true + }, + { + "key": "rate", + "name_ko": "할증률", + "unit": "%", + "visible": true + }, + { + "key": "pumsem", + "name_ko": "어느 품셈", + "unit": "", + "visible": true + }, + { + "key": "listed_in", + "name_ko": "실린 자리", + "unit": "", + "visible": true + }, + { + "key": "listed_condition", + "name_ko": "원문이 못박은 조건", + "unit": "", + "visible": true + }, + { + "key": "our_usage", + "name_ko": "우리 쓰임", + "unit": "", + "visible": true + }, + { + "key": "why_not_applied", + "name_ko": "안 쓰는 사유", + "unit": "", + "visible": true + } + ] + }, + { + "key": "rates_pct", + "name_ko": "재료 할증률", + "summary": "자재마다 할증률과 그 조건. 조건이 갈리면 다른 조건 칸에 함께 둔다.", + "shape": "list", + "rows": 19, + "columns": [ + { + "key": "material", + "name_ko": "자재명", + "unit": "", + "visible": true + }, + { + "key": "rate", + "name_ko": "할증률", + "unit": "%", + "visible": true + }, + { + "key": "condition", + "name_ko": "조건", + "unit": "", + "visible": true + }, + { + "key": "alt_rate", + "name_ko": "다른 조건 할증률", + "unit": "%", + "visible": true + }, + { + "key": "alt_condition", + "name_ko": "다른 조건", + "unit": "", + "visible": true + }, + { + "key": "pumsem", + "name_ko": "어느 품셈", + "unit": "", + "visible": true + } + ] + }, + { + "key": "source", + "name_ko": "출처", + "summary": "할증률표가 어느 품셈에서 왔는지.", + "shape": "map", + "rows": 2, + "columns": [ + { + "key": "doc", + "name_ko": "문서", + "unit": "", + "visible": true + }, + { + "key": "via", + "name_ko": "거쳐 온 자리", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "candidates_pending_user/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "not_found", + "name_ko": "못 찾은 것", + "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것." + }, + { + "key": "observed_practice", + "name_ko": "실무 관측", + "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + } + ] + }, + { + "file_id": "formwork_reuse", + "path": "resources/data_formwork/formwork_reuse_2026-01-01.json", + "name_ko": "거푸집 사용횟수", + "summary": "구조물 종류마다 거푸집을 몇 번 쓰는지. 품셈 1-7-1 이 정한 값이라 실무값으로 갈음하지 않는다.", + "kind": "logic", + "tables": [ + { + "key": "euroform_type/classes", + "name_ko": "유로폼 갈래별 1일 시공량", + "summary": "유로폼 설치·해체 품이 갈리는 갈래와 하루 시공 면적.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "key", + "name_ko": "키", + "unit": "", + "visible": true + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + }, + { + "key": "daily_area_m2", + "name_ko": "1일 시공량", + "unit": "㎡", + "visible": true + } + ] + }, + { + "key": "euroform_type/type_map", + "name_ko": "구조물 종류 ↔ 유로폼 갈래", + "summary": "우리 구조물 종류 키를 유로폼 갈래에 잇는 줄.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "matched", + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + }, + { + "key": "reuse_by_class", + "name_ko": "구조 복잡도별 사용횟수", + "summary": "품셈 1-7-1 이 구조 갈래마다 정한 거푸집 사용횟수.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "reuse_count", + "name_ko": "사용횟수", + "unit": "회", + "visible": true + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + } + ] + }, + { + "key": "type_map", + "name_ko": "구조물 종류 ↔ 사용횟수", + "summary": "우리 구조물 종류 키를 위 갈래에 잇는 줄.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "reuse_count", + "name_ko": "사용횟수", + "unit": "회", + "visible": true + }, + { + "key": "matched_example", + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "basis", + "name_ko": "근거", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "euroform_type/material_unit_note", + "name_ko": "자재 밑수 비고", + "summary": "자재 환산을 어느 창이 하는지." + }, + { + "key": "euroform_type/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "euroform_type/reuse_note", + "name_ko": "사용횟수 비고", + "summary": "닮은 두 사용횟수를 하나로 잇지 않는 까닭." + }, + { + "key": "euroform_type/source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "reuse_ratio_pct", + "name_ko": "사용횟수별 기준수량 비율", + "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다." + }, + { + "key": "shoring", + "name_ko": "동바리", + "summary": "강관동바리가 어느 구조물에 서는지." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + } + ] + }, + { + "file_id": "rebar_complexity", + "path": "resources/data_rebar/rebar_complexity_2026-01-01.json", + "name_ko": "철근 가공·조립 갈래", + "summary": "구조물 형식이 간단·보통·복잡 어디인지. 품셈 12-3 [주]① 이 예시로 가른다.", + "kind": "logic", + "tables": [ + { + "key": "classes", + "name_ko": "철근 갈래", + "summary": "간단·보통·복잡·매우복잡과 원문 예시.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "key", + "name_ko": "키", + "unit": "", + "visible": true + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + } + ] + }, + { + "key": "form_map", + "name_ko": "구조물 형식 ↔ 철근 갈래", + "summary": "우리 구조물 형식을 위 갈래에 잇는 줄.", + "shape": "list", + "rows": 6, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "form", + "name_ko": "형식", + "unit": "", + "visible": true + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "matched", + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "basis", + "name_ko": "근거", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "price_hint_krw_per_ton", + "name_ko": "단가 참고값", + "summary": "표시 전용 — 갈래 차이를 사람이 보라고 둔 값. 계산에 안 들어간다." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + } + ] + }, + { + "file_id": "timber_structure_class", + "path": "resources/data_timber/timber_structure_class_2026-01-01.json", + "name_ko": "목재공작물 구조 갈래", + "summary": "통나무 구조물의 품이 갈리는 자리. 품셈 13-13-1 [주]③.", + "kind": "logic", + "tables": [ + { + "key": "classes", + "name_ko": "목재 구조 갈래별 품", + "summary": "갈래마다 목재 채적 1㎥에 드는 건축목공·보통인부.", + "shape": "list", + "rows": 6, + "columns": [ + { + "key": "key", + "name_ko": "키", + "unit": "", + "visible": true + }, + { + "key": "carpenter", + "name_ko": "건축목공", + "unit": "인/㎥", + "visible": true + }, + { + "key": "laborer", + "name_ko": "보통인부", + "unit": "인/㎥", + "visible": true + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + } + ] + }, + { + "key": "type_map", + "name_ko": "구조물 종류 ↔ 목재 갈래", + "summary": "우리 구조물 종류 키를 위 갈래에 잇는 줄.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "matched", + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + { + "key": "provisional", + "name_ko": "잠정 여부", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "compare", + "name_ko": "견줌", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "basis_unit", + "name_ko": "밑수", + "summary": "값이 무엇 하나당인지." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "pending_user", + "name_ko": "사용자 확정 대기", + "summary": "아직 사람이 답해야 닫히는 물음." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + } + ] + }, + { + "file_id": "masonry_class", + "path": "resources/data_masonry/masonry_class_2026-01-01.json", + "name_ko": "돌쌓기 규격 갈래", + "summary": "저장 제원으로 자동 판정하는 축 — 뒷길이·돌 직경·전면 기울기·쌓기 방식.", + "kind": "logic", + "tables": [ + { + "key": "back_length/classes", + "name_ko": "뒷길이 갈래", + "summary": "돌쌓기 뒷길이를 품셈 규격 칸으로 가르는 상한.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "max_cm", + "name_ko": "상한", + "unit": "㎝", + "visible": true + }, + { + "key": "key", + "name_ko": "키", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "back_length/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "back_length/option_key", + "name_ko": "고르개 키", + "summary": "화면 고르개가 쓰는 저장 칸 이름." + }, + { + "key": "back_length/source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + }, + { + "key": "bond", + "name_ko": "쌓기 방식", + "summary": "메쌓기·찰쌓기로 공종코드가 갈린다." + }, + { + "key": "boulder_diameter", + "name_ko": "돌 직경 갈래", + "summary": "큰돌쌓기 — 저장 제원 stone_cm 으로 갈린다." + }, + { + "key": "face_slope", + "name_ko": "전면 기울기", + "summary": "형식마다 정해진 1:n. 코드 기본 0.3 이 여기서 왔다." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "masonry_slope", + "path": "resources/data_masonry/masonry_slope_2026-01-01.json", + "name_ko": "돌쌓기 표준경사", + "summary": "직고·메찰·성절토 셋으로 갈리는 전면 기울기. 품셈 13-4-4 [주]⑪.", + "kind": "logic", + "tables": [ + { + "key": "table", + "name_ko": "표준경사 표", + "summary": "메·찰마다 성토·절토 기울기 줄. 직고 칸은 steps_m 이 정한다.", + "shape": "map", + "rows": 2, + "columns": [ + { + "key": "성토", + "name_ko": "성토부 경사", + "unit": "1:n", + "visible": true + }, + { + "key": "절토", + "name_ko": "절토부 경사", + "unit": "1:n", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "not_here", + "name_ko": "이 표가 안 다루는 것", + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + }, + { + "key": "steps_m", + "name_ko": "직고 칸", + "summary": "표가 갈리는 높이 경계(m)." + } + ] + }, + { + "file_id": "masonry_back_length", + "path": "resources/data_masonry/masonry_back_length_2026-01-01.json", + "name_ko": "돌쌓기 뒷길이 표준", + "summary": "높이와 메·찰로 갈리는 뒷길이 범위. 품셈 13-4-4 [주]⑩.", + "kind": "logic", + "tables": [], + "value_groups": [ + { + "key": "not_here", + "name_ko": "이 표가 안 다루는 것", + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + }, + { + "key": "steps_m", + "name_ko": "직고 칸", + "summary": "표가 갈리는 높이 경계(m)." + }, + { + "key": "table_cm", + "name_ko": "뒷길이 범위표", + "summary": "메·찰마다 직고 칸별 뒷길이 하한·상한(㎝)." + } + ] + }, + { + "file_id": "stone_kind", + "path": "resources/data_masonry/stone_kind_2026-01-01.json", + "name_ko": "돌 종류별 소요량", + "summary": "고임돌·채움 콘크리트·뒤채움이 돌 종류로 갈리는 자리.", + "kind": "logic", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "돌 종류 계수가 어느 원문에서 왔는지.", + "shape": "map", + "rows": 4, + "columns": [ + { + "key": "doc", + "name_ko": "문서", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "quote", + "name_ko": "원문 인용", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "back_lengths_cm", + "name_ko": "뒷길이 규격", + "summary": "표가 값을 주는 뒷길이 일곱 칸." + }, + { + "key": "backfill_ratio_of_back_length", + "name_ko": "뒤채움 몫", + "summary": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이다." + }, + { + "key": "fallback", + "name_ko": "미지정일 때", + "summary": "사용자가 안 고른 경우 쓰는 한 벌." + }, + { + "key": "fill_concrete_m3_per_m2", + "name_ko": "채움 콘크리트 원단위", + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. 원문이 두 줄뿐이다." + }, + { + "key": "kinds", + "name_ko": "돌 종류", + "summary": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌 네 줄." + }, + { + "key": "no_folding", + "name_ko": "접지 않음 규칙", + "summary": "표에 없는 뒷길이를 가까운 칸으로 접지 않는다 — 접으면 값이 조용히 틀린다." + }, + { + "key": "not_here", + "name_ko": "이 표가 안 다루는 것", + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "option_key", + "name_ko": "고르개 키", + "summary": "화면 고르개가 쓰는 저장 칸 이름." + }, + { + "key": "option_note", + "name_ko": "고르개 비고", + "summary": "고르개를 만들 때 챙길 것." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "wedge_stone_m3_per_m2", + "name_ko": "고임돌 원단위", + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. null 은 원문 「-」다." + }, + { + "key": "why", + "name_ko": "까닭", + "summary": "이 벌을 왜 따로 두는지." + } + ] + }, + { + "file_id": "revetment_sabang", + "path": "resources/data_masonry/revetment_sabang_2026-01-01.json", + "name_ko": "기슭막이 치수(사방)", + "summary": "독립 기슭막이의 높이·계획비탈·둑마루 두께·뒷채움을 한 자리에 둔 벌.", + "kind": "logic", + "tables": [], + "value_groups": [ + { + "key": "items", + "name_ko": "항목", + "summary": "이 벌이 담는 항목 묶음." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "rank", + "name_ko": "우선순위", + "summary": "원문끼리 어긋날 때 어느 쪽을 먼저 보는지." + }, + { + "key": "scope", + "name_ko": "걸치는 범위", + "summary": "어디까지 쓰는 값인지." + } + ] + }, + { + "file_id": "structure_unit_observed", + "path": "resources/data_structure_unit/structure_unit_observed_2026-01-01.json", + "name_ko": "구조물 관측 원단위", + "summary": "품셈에 표준 물량표가 없는 콘크리트 구조물을 실무 설계원본에서 뽑은 벌.", + "kind": "logic", + "tables": [ + { + "key": "double_count_rules/rules", + "name_ko": "이중계상 방지 규칙", + "summary": "같은 것을 두 번 세게 되는 자리와 우리가 고른 쪽.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "key", + "name_ko": "규칙·물음 키", + "unit": "", + "visible": true + }, + { + "key": "where", + "name_ko": "어디서 갈리나", + "unit": "", + "visible": true + }, + { + "key": "quote", + "name_ko": "원문 인용", + "unit": "", + "visible": true + }, + { + "key": "our_choice", + "name_ko": "우리가 고른 쪽", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "scope", + "name_ko": "걸치는 범위", + "unit": "", + "visible": true + }, + { + "key": "guard", + "name_ko": "가드", + "unit": "", + "visible": true + } + ] + }, + { + "key": "entries", + "name_ko": "구조물 원단위", + "summary": "구조물 종류·규격마다 개소당(또는 m당) 물량 구성.", + "shape": "list", + "rows": 10, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "spec", + "name_ko": "제원", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "source", + "name_ko": "원천", + "unit": "", + "visible": true + }, + { + "key": "source_note", + "name_ko": "원천 비고", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "원본 절", + "unit": "", + "visible": true + }, + { + "key": "components", + "name_ko": "구성 물량", + "unit": "", + "visible": true + }, + { + "key": "notes", + "name_ko": "비고 묶음", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + }, + { + "key": "not_found/items", + "name_ko": "못 찾은 원단위", + "summary": "원본에 값이 없어 못 세운 자리와 무엇이 있어야 열리는지.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "needs", + "name_ko": "있어야 열리는 것", + "unit": "", + "visible": true + }, + { + "key": "spec", + "name_ko": "제원", + "unit": "", + "visible": true + }, + { + "key": "about", + "name_ko": "무엇에 대한 것인가", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + }, + { + "key": "pending_choices/items", + "name_ko": "사용자 확정 대기", + "summary": "갈래가 갈리는데 아직 안 고른 물음과 고르면 생기는 차이.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "key", + "name_ko": "규칙·물음 키", + "unit": "", + "visible": true + }, + { + "key": "label", + "name_ko": "칸 이름", + "unit": "", + "visible": true + }, + { + "key": "default", + "name_ko": "기본값", + "unit": "", + "visible": true + }, + { + "key": "where", + "name_ko": "어디서 갈리나", + "unit": "", + "visible": true + }, + { + "key": "effect", + "name_ko": "바뀌면 생기는 차이", + "unit": "", + "visible": true + }, + { + "key": "scope", + "name_ko": "걸치는 범위", + "unit": "", + "visible": true + } + ] + }, + { + "key": "sources", + "name_ko": "출처", + "summary": "관측 원단위가 어느 실무 원본에서 왔는지.", + "shape": "map", + "rows": 2, + "columns": [ + { + "key": "doc", + "name_ko": "문서", + "unit": "", + "visible": true + }, + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + } + ] + }, + { + "key": "原文_뒷받침/items", + "name_ko": "원문 뒷받침", + "summary": "관측값이 품셈 원문과 맞는지 대 본 기록.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "item", + "name_ko": "항목", + "unit": "", + "visible": true + }, + { + "key": "observed", + "name_ko": "관측값", + "unit": "", + "visible": true + }, + { + "key": "source", + "name_ko": "원천", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "double_count_rules/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "not_found/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "pending_choices/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "原文_뒷받침/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "work_item_mapping", + "path": "resources/data_work_item_mapping/work_item_mapping_2026-01-01.json", + "name_ko": "수량 줄 ↔ 공종코드 잇기", + "summary": "B08 이 낸 수량 줄을 공종 마스터 코드에 잇는 다리. 발주처 골격이 바뀌어도 코드를 안 고치려고 데이터로 둔다.", + "kind": "logic", + "tables": [ + { + "key": "ancillary", + "name_ko": "부대공 잇기", + "summary": "가설창고 같은 부대공 줄.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "group", + "name_ko": "묶음", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "basis_unit", + "name_ko": "밑수 단위", + "unit": "", + "visible": true + }, + { + "key": "basis_source", + "name_ko": "밑수 근거", + "unit": "", + "visible": true + }, + { + "key": "master_name", + "name_ko": "마스터 공종명", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + } + ] + }, + { + "key": "composite/items", + "name_ko": "묶음 공종", + "summary": "품셈에 그 공종이 없어 여러 공종을 묶어 한 줄로 세우는 것(옹벽·BOX암거·포장).", + "shape": "list", + "rows": 11, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "when", + "name_ko": "서는 조건", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "outside_note", + "name_ko": "묶음 밖 비고", + "unit": "", + "visible": true + }, + { + "key": "parts", + "name_ko": "조각", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "needs", + "name_ko": "있어야 열리는 것", + "unit": "", + "visible": true + }, + { + "key": "placing_note", + "name_ko": "타설 비고", + "unit": "", + "visible": true + }, + { + "key": "parts_note", + "name_ko": "조각 비고", + "unit": "", + "visible": true + } + ] + }, + { + "key": "earthwork", + "name_ko": "토공 잇기", + "summary": "흙깎기·쌓기·면고르기 같은 토공 줄을 공종코드에 잇는다.", + "shape": "list", + "rows": 22, + "columns": [ + { + "key": "group", + "name_ko": "묶음", + "unit": "", + "visible": true + }, + { + "key": "ground", + "name_ko": "지반 갈래", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "basis_unit", + "name_ko": "밑수 단위", + "unit": "", + "visible": true + }, + { + "key": "basis_source", + "name_ko": "밑수 근거", + "unit": "", + "visible": true + }, + { + "key": "master_name", + "name_ko": "마스터 공종명", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "variant_axis", + "name_ko": "갈래 축", + "unit": "", + "visible": true + }, + { + "key": "variant_from", + "name_ko": "갈래 원본 칸", + "unit": "", + "visible": true + }, + { + "key": "variant_note", + "name_ko": "갈래 비고", + "unit": "", + "visible": true + }, + { + "key": "mismatch_reason", + "name_ko": "어긋남 사유", + "unit": "", + "visible": true + }, + { + "key": "mismatch_kind", + "name_ko": "어긋남 갈래", + "unit": "", + "visible": true + }, + { + "key": "variant_missing_reason", + "name_ko": "갈래 미선택 사유", + "unit": "", + "visible": true + }, + { + "key": "leaf_from", + "name_ko": "잎 원본 칸", + "unit": "", + "visible": true + }, + { + "key": "leaf_codes", + "name_ko": "잎 코드", + "unit": "", + "visible": true + }, + { + "key": "leaf_note", + "name_ko": "잎 비고", + "unit": "", + "visible": true + }, + { + "key": "item", + "name_ko": "항목", + "unit": "", + "visible": true + }, + { + "key": "variant_value", + "name_ko": "갈래 값", + "unit": "", + "visible": true + }, + { + "key": "variant_template", + "name_ko": "갈래 틀", + "unit": "", + "visible": true + } + ] + }, + { + "key": "haul", + "name_ko": "운반 잇기", + "summary": "운반 수단마다 공종코드. 무대(20m 이내)는 내역 줄이 아니다.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "equipment", + "name_ko": "운반 수단", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "basis_unit", + "name_ko": "밑수 단위", + "unit": "", + "visible": true + }, + { + "key": "basis_source", + "name_ko": "밑수 근거", + "unit": "", + "visible": true + }, + { + "key": "master_name", + "name_ko": "마스터 공종명", + "unit": "", + "visible": true + }, + { + "key": "payload_density_note", + "name_ko": "적재 단위중량 비고", + "unit": "", + "visible": true + }, + { + "key": "in_bill", + "name_ko": "내역에 싣나", + "unit": "", + "visible": true + }, + { + "key": "reason", + "name_ko": "사유", + "unit": "", + "visible": true + } + ] + }, + { + "key": "pending_user/items", + "name_ko": "사용자 확정 대기", + "summary": "어느 공종으로 볼지 아직 안 정한 줄.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "group", + "name_ko": "묶음", + "unit": "", + "visible": true + }, + { + "key": "candidates", + "name_ko": "후보", + "unit": "", + "visible": true + }, + { + "key": "why", + "name_ko": "까닭", + "unit": "", + "visible": true + }, + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "이름", + "unit": "", + "visible": true + }, + { + "key": "needs", + "name_ko": "있어야 열리는 것", + "unit": "", + "visible": true + } + ] + }, + { + "key": "structure", + "name_ko": "구조물 잇기", + "summary": "구조물 종류를 공종코드에 잇고 내역 성분·밑수 단위를 못박는다.", + "shape": "list", + "rows": 9, + "columns": [ + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + { + "key": "secondary_axes", + "name_ko": "둘째 갈래 축", + "unit": "", + "visible": true + }, + { + "key": "secondary_axes_note", + "name_ko": "둘째 갈래 비고", + "unit": "", + "visible": true + }, + { + "key": "billing_component", + "name_ko": "내역 성분", + "unit": "", + "visible": true + }, + { + "key": "billing_note", + "name_ko": "내역 비고", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "master_name", + "name_ko": "마스터 공종명", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "class_note", + "name_ko": "갈래 비고", + "unit": "", + "visible": true + }, + { + "key": "variant_axis", + "name_ko": "갈래 축", + "unit": "", + "visible": true + }, + { + "key": "class_from", + "name_ko": "갈래 원본 칸", + "unit": "", + "visible": true + }, + { + "key": "form_codes", + "name_ko": "형식별 공종코드", + "unit": "", + "visible": true + }, + { + "key": "basis_unit_note", + "name_ko": "밑수 비고", + "unit": "", + "visible": true + }, + { + "key": "bond_codes", + "name_ko": "쌓기 방식별 공종코드", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "composite/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "composite/unit_conversion", + "name_ko": "단위 환산", + "summary": "단가 단위와 원단위 단위가 달라 맞추는 값." + }, + { + "key": "concrete_placing", + "name_ko": "콘크리트 타설 잇기", + "summary": "타설 방식 × 구조물 종류로 공종이 갈리는 자리." + }, + { + "key": "ground_aliases_moved_to", + "name_ko": "갈래 별칭 옮긴 자리", + "summary": "갈래 이름 별칭은 별칭표로 옮겼다. 여기 다시 두지 않는다." + }, + { + "key": "masonry_class_reference", + "name_ko": "돌쌓기 갈래 참고 자리", + "summary": "돌쌓기 갈래는 이제 참고용이고 어긋나면 그것이 신호다." + }, + { + "key": "master", + "name_ko": "마스터 자리", + "summary": "공종 코드를 읽어 오는 마스터 파일." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "pending_user/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "pipe", + "name_ko": "배수관 잇기", + "summary": "관종으로 공종이 갈린다. 관 정본은 pipe_points 다." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "variant_contract", + "name_ko": "갈래 키 계약", + "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속." + } + ] + }, + { + "file_id": "aliases", + "path": "resources/data_aliases/aliases_2026-01-01.json", + "name_ko": "별칭표", + "summary": "자원·갈래 이름이 달라도 같은 것으로 잇는 한 벌.", + "kind": "logic", + "tables": [ + { + "key": "aliases", + "name_ko": "별칭 줄", + "summary": "원래 이름 하나를 표준 코드 하나에 잇는 줄. 범위(scope)와 품셈 판까지 달아 어디에 걸리는지 못박는다.", + "shape": "list", + "rows": 20, + "columns": [ + { + "key": "axis", + "name_ko": "축", + "unit": "", + "visible": true + }, + { + "key": "from", + "name_ko": "원래 이름", + "unit": "", + "visible": true + }, + { + "key": "to", + "name_ko": "이을 코드", + "unit": "", + "visible": true + }, + { + "key": "scope", + "name_ko": "걸치는 범위", + "unit": "", + "visible": true + }, + { + "key": "pum_edition", + "name_ko": "품셈 판", + "unit": "", + "visible": true + }, + { + "key": "basis", + "name_ko": "근거", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "resource_catalog_ext", + "path": "resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json", + "name_ko": "추가 자원 카탈로그", + "summary": "노임·기계·관급 카탈로그에 없는 자원의 이름·규격·단위. 단가 칸은 없다.", + "kind": "logic", + "tables": [ + { + "key": "entries", + "name_ko": "추가 자원", + "summary": "카탈로그에 없던 자원의 코드·이름·규격·단위.", + "shape": "list", + "rows": 40, + "columns": [ + { + "key": "code", + "name_ko": "자원코드", + "unit": "", + "visible": true + }, + { + "key": "kind", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "이름", + "unit": "", + "visible": true + }, + { + "key": "spec", + "name_ko": "규격", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "source", + "name_ko": "어디서 왔나", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + } + ] + }, + { + "file_id": "labor_const", + "path": "resources/data_cost_input_value/labor_const_2026-01-01.json", + "name_ko": "건설업 노임단가", + "summary": "대한건설협회가 공표하는 시중노임 — 직종마다 하루 얼마.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/labor_rate/records", + "name_ko": "건설업 직종별 노임", + "summary": "직종마다 하루 노임. 일위대가의 품값이 여기서 온다.", + "shape": "list", + "rows": 132, + "columns": [ + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true + }, + { + "key": "occupation_name", + "name_ko": "직종명", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "hours_per_day", + "name_ko": "1일 근로시간", + "unit": "시간", + "visible": true + }, + { + "key": "daily_wage_krw", + "name_ko": "일 노임", + "unit": "원", + "visible": true + }, + { + "key": "status", + "name_ko": "공표 상태", + "unit": "", + "visible": true + }, + { + "key": "reliability", + "name_ko": "신뢰도 기호", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "variables/aliases", + "name_ko": "노임 별칭", + "summary": "코드 대신 쓰는 별칭 이름표(labor_1002 = 보통인부)." + }, + { + "key": "variables/labor_rate/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/labor_rate/reliability_note", + "name_ko": "신뢰도 기호 뜻풀이", + "summary": "노임 표의 * · ** 가 무엇을 뜻하는지." + } + ] + }, + { + "file_id": "labor_mfg", + "path": "resources/data_cost_input_value/labor_mfg_2026-07-01.json", + "name_ko": "제조업 노임단가", + "summary": "중소기업중앙회가 공표하는 중소제조업 직종별 임금.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/labor_mfg/records", + "name_ko": "제조업 직종별 노임", + "summary": "중소제조업 직종마다 하루 임금.", + "shape": "list", + "rows": 129, + "columns": [ + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true + }, + { + "key": "occupation_name", + "name_ko": "직종명", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "industry", + "name_ko": "업종", + "unit": "", + "visible": true + }, + { + "key": "daily_wage_krw", + "name_ko": "일 노임", + "unit": "원", + "visible": true + }, + { + "key": "status", + "name_ko": "공표 상태", + "unit": "", + "visible": true + }, + { + "key": "variation_coefficient", + "name_ko": "변동계수", + "unit": "%", + "visible": true + }, + { + "key": "source_flag", + "name_ko": "원문 표시", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "variables/labor_mfg/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + } + ] + }, + { + "file_id": "mach_base", + "path": "resources/data_cost_input_value/mach_base_2026.json", + "name_ko": "건설기계 기초값", + "summary": "취득가·시간당 손료계수·연료소모량·운전원 직종 네 벌.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mach_fuel_rate/parsed_records", + "name_ko": "기계 연료소모량·운전경비", + "summary": "기계마다 한 시간에 쓰는 연료량과 잡재료비 비율, 운전원 수.", + "shape": "list", + "rows": 214, + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true + }, + { + "key": "fuel_type", + "name_ko": "연료 종류", + "unit": "", + "visible": true + }, + { + "key": "fuel_rate_l_per_hour", + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true + }, + { + "key": "misc_material_percent_of_fuel", + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true + }, + { + "key": "operator_person_per_day", + "name_ko": "운전원", + "unit": "인/일", + "visible": true + }, + { + "key": "parse_method", + "name_ko": "읽은 방법", + "unit": "", + "visible": false + } + ] + }, + { + "key": "variables/mach_fuel_rate/source_tables", + "name_ko": "운전경비 원문 표", + "summary": "위 값을 읽어 낸 건설품셈 8-4 표를 원문 그대로 실은 벌.", + "shape": "list", + "rows": 21, + "columns": [ + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "rows", + "name_ko": "표 줄", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mach_loss_coef/records", + "name_ko": "기계 시간당 손료계수", + "summary": "취득가에 곱해 한 시간 손료를 내는 계수와 그 속을 이루는 상각·정비·관리 몫.", + "shape": "list", + "rows": 387, + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true + }, + { + "key": "loss_coefficient_per_hour", + "name_ko": "시간당 손료계수", + "unit": "", + "visible": true + }, + { + "key": "source_coefficient_1e_minus_7", + "name_ko": "원문 손료계수", + "unit": "10^-7", + "visible": false + }, + { + "key": "economic_life_hours", + "name_ko": "내용시간", + "unit": "시간", + "visible": true + }, + { + "key": "annual_standard_hours", + "name_ko": "연간표준가동시간", + "unit": "시간", + "visible": true + }, + { + "key": "depreciation_ratio", + "name_ko": "상각비율", + "unit": "", + "visible": true + }, + { + "key": "maintenance_ratio", + "name_ko": "정비비율", + "unit": "", + "visible": true + }, + { + "key": "annual_management_ratio", + "name_ko": "연간관리비율", + "unit": "", + "visible": true + }, + { + "key": "depreciation_coefficient_1e_minus_7", + "name_ko": "원문 상각비계수", + "unit": "10^-7", + "visible": false + }, + { + "key": "maintenance_coefficient_1e_minus_7", + "name_ko": "원문 정비비계수", + "unit": "10^-7", + "visible": false + }, + { + "key": "management_coefficient_1e_minus_7", + "name_ko": "원문 관리비계수", + "unit": "10^-7", + "visible": false + } + ] + }, + { + "key": "variables/mach_operator_map/explicit_mappings", + "name_ko": "기계별 운전원 직종", + "summary": "기계 하나하나에 붙은 운전원 직종코드.", + "shape": "list", + "rows": 121, + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mach_operator_map/rules", + "name_ko": "기계 ↔ 운전원 규칙", + "summary": "기계 계열로 운전원 직종을 정하는 규칙.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true + }, + { + "key": "alias", + "name_ko": "별칭 키", + "unit": "", + "visible": true + }, + { + "key": "rule_id", + "name_ko": "규칙 키", + "unit": "", + "visible": true + }, + { + "key": "source_section", + "name_ko": "원문 절", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mach_price/records", + "name_ko": "기계 취득가", + "summary": "기계·규격마다 새로 살 때 값(천원). 손료 계산의 밑수다.", + "shape": "list", + "rows": 613, + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true + }, + { + "key": "price_thousand_krw", + "name_ko": "취득가", + "unit": "천원", + "visible": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mach_rock_adj/rules", + "name_ko": "암 작업 손료 할증", + "summary": "암을 다룰 때 기계 손료를 몇 % 올리는지.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "machine_group", + "name_ko": "기계 묶음", + "unit": "", + "visible": true + }, + { + "key": "rock_work", + "name_ko": "암 작업 할증", + "unit": "%", + "visible": true + }, + { + "key": "boulder_mixed_soil", + "name_ko": "호박돌 섞인 토사 할증", + "unit": "%", + "visible": true + }, + { + "key": "exclusion", + "name_ko": "제외", + "unit": "", + "visible": true + }, + { + "key": "exception", + "name_ko": "예외", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "parse_audit", + "name_ko": "읽기 점검", + "summary": "취득가 표에서 못 읽은 줄이 있는지 적어 둔 자리." + }, + { + "key": "variables/mach_fuel_rate/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/mach_fuel_rate/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "variables/mach_fuel_rate/unit", + "name_ko": "값 단위", + "summary": "연료량은 L/시간이다." + }, + { + "key": "variables/mach_loss_coef/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/mach_loss_coef/unit", + "name_ko": "값 단위", + "summary": "손료계수는 1/시간이다." + }, + { + "key": "variables/mach_operator_map/note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "variables/mach_price/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + { + "key": "variables/mach_price/unit", + "name_ko": "값 단위", + "summary": "취득가는 천원 단위다." + }, + { + "key": "variables/mach_rock_adj/unit", + "name_ko": "값 단위", + "summary": "할증은 %다." + } + ] + }, + { + "file_id": "machine_operating", + "path": "resources/data_cost_machine_operating/machine_operating_2026.json", + "name_ko": "기계 운전경비", + "summary": "건설품셈 8-4 에서 뽑은 운전경비 벌. mach_fuel_rate 가 다 차면 걷어낼 자리다.", + "kind": "base_value", + "tables": [ + { + "key": "records", + "name_ko": "기계 운전경비", + "summary": "기계마다 연료 종류·시간당 연료량·잡재료비·운전원.", + "shape": "list", + "rows": 92, + "columns": [ + { + "key": "fuel_kind", + "name_ko": "연료 종류", + "unit": "", + "visible": true + }, + { + "key": "fuel_liters_per_hour", + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true + }, + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true + }, + { + "key": "misc_material_percent", + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true + }, + { + "key": "operator_mapping_is_provisional", + "name_ko": "운전원 매칭 잠정 여부", + "unit": "", + "visible": true + }, + { + "key": "operator_occupation_code", + "name_ko": "운전원 직종코드", + "unit": "", + "visible": true + }, + { + "key": "operator_person_days", + "name_ko": "운전원", + "unit": "인/일", + "visible": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "derived_from", + "name_ko": "무엇에서 나왔나", + "summary": "어느 벌을 풀어 만든 파생본인지." + }, + { + "key": "dropped_tables", + "name_ko": "못 읽어 버린 표", + "summary": "칸 수가 안 맞아 못 읽고 버린 원문 표." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "stats", + "name_ko": "집계", + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + } + ] + }, + { + "file_id": "mat_price_public", + "path": "resources/data_cost_input_value/mat_price_public_2026-08-14.json", + "name_ko": "관급자재 단가", + "summary": "나라장터 시설공통자재 단가 벌. 마스터에서 가장 큰 표다.", + "kind": "base_value", + "tables": [ + { + "key": "excluded_named_groups", + "name_ko": "제외한 자재 묶음", + "summary": "이 벌에 안 실은 자재 묶음과 그 사유(철근·레미콘·아스콘).", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "group", + "name_ko": "묶음", + "unit": "", + "visible": true + }, + { + "key": "reason", + "name_ko": "사유", + "unit": "", + "visible": true + } + ] + }, + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/mat_price/records", + "name_ko": "관급자재 단가", + "summary": "물품마다 규격·단위·단가. 마스터에서 가장 큰 표다.", + "shape": "list", + "rows": 6999, + "columns": [ + { + "key": "item_code", + "name_ko": "물품코드", + "unit": "", + "visible": true + }, + { + "key": "classification_code", + "name_ko": "물품분류번호", + "unit": "", + "visible": true + }, + { + "key": "classification_name", + "name_ko": "물품분류명", + "unit": "", + "visible": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "price_krw", + "name_ko": "단가", + "unit": "원", + "visible": true + }, + { + "key": "notice_datetime", + "name_ko": "공고 일시", + "unit": "", + "visible": true + }, + { + "key": "notice_number", + "name_ko": "공고번호", + "unit": "", + "visible": false + }, + { + "key": "business_division_code", + "name_ko": "사업구분 코드", + "unit": "", + "visible": false + }, + { + "key": "business_division_name", + "name_ko": "사업구분", + "unit": "", + "visible": true + }, + { + "key": "vat_basis", + "name_ko": "부가세 기준", + "unit": "", + "visible": true + }, + { + "key": "price_type", + "name_ko": "가격 종류", + "unit": "", + "visible": true + }, + { + "key": "delivery_condition", + "name_ko": "납품 조건", + "unit": "", + "visible": true + }, + { + "key": "field", + "name_ko": "분야", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "selection_policy", + "name_ko": "고르는 방침", + "summary": "같은 물품이 여러 번 공고되면 어느 것을 쓰는지." + }, + { + "key": "source_row_count", + "name_ko": "원천 줄 수", + "summary": "걸러 내기 전 원본 줄 수." + }, + { + "key": "variables/mat_price/key", + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + } + ] + }, + { + "file_id": "oil", + "path": "resources/data_cost_input_value/oil_2026-08-14.json", + "name_ko": "유가 — 전국평균", + "summary": "휘발유·경유 전국평균 단가.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables", + "name_ko": "전국평균 유가", + "summary": "휘발유·경유 전국평균 단가. 키가 곧 유종이다.", + "shape": "map", + "rows": 2, + "columns": [ + { + "key": "value", + "name_ko": "단가", + "unit": "원/L", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "scope", + "name_ko": "범위", + "unit": "", + "visible": true + }, + { + "key": "date", + "name_ko": "기준일", + "unit": "", + "visible": true + }, + { + "key": "source_product_code", + "name_ko": "원천 제품코드", + "unit": "", + "visible": false + }, + { + "key": "source_product_name", + "name_ko": "원천 제품명", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [] + }, + { + "file_id": "oil_regional", + "path": "resources/data_cost_input_value/oil_regional_2026-09-09.json", + "name_ko": "유가 — 시도별", + "summary": "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」를 따르려고 둔 시도별 단가.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/oil_diesel/records", + "name_ko": "시도별 경유 단가", + "summary": "시도마다 경유 단가. 코드 00 은 전국이다.", + "shape": "list", + "rows": 17, + "columns": [ + { + "key": "sido_code", + "name_ko": "시도코드", + "unit": "", + "visible": true + }, + { + "key": "sido_name", + "name_ko": "시도명", + "unit": "", + "visible": true + }, + { + "key": "value", + "name_ko": "단가", + "unit": "원/L", + "visible": true + } + ] + }, + { + "key": "variables/oil_gasoline/records", + "name_ko": "시도별 휘발유 단가", + "summary": "시도마다 휘발유 단가. 코드 00 은 전국이다.", + "shape": "list", + "rows": 17, + "columns": [ + { + "key": "sido_code", + "name_ko": "시도코드", + "unit": "", + "visible": true + }, + { + "key": "sido_name", + "name_ko": "시도명", + "unit": "", + "visible": true + }, + { + "key": "value", + "name_ko": "단가", + "unit": "원/L", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "variables/oil_diesel/date", + "name_ko": "기준일", + "summary": "값이 선 날." + }, + { + "key": "variables/oil_diesel/scope", + "name_ko": "걸치는 범위", + "summary": "어디까지 쓰는 값인지." + }, + { + "key": "variables/oil_diesel/source_product_code", + "name_ko": "원천 제품코드", + "summary": "원천이 쓰는 제품 코드." + }, + { + "key": "variables/oil_diesel/source_product_name", + "name_ko": "원천 제품명", + "summary": "원천이 쓰는 제품 이름." + }, + { + "key": "variables/oil_diesel/unit", + "name_ko": "값 단위", + "summary": "단가는 원/L 이다." + }, + { + "key": "variables/oil_gasoline/date", + "name_ko": "기준일", + "summary": "값이 선 날." + }, + { + "key": "variables/oil_gasoline/scope", + "name_ko": "걸치는 범위", + "summary": "어디까지 쓰는 값인지." + }, + { + "key": "variables/oil_gasoline/source_product_code", + "name_ko": "원천 제품코드", + "summary": "원천이 쓰는 제품 코드." + }, + { + "key": "variables/oil_gasoline/source_product_name", + "name_ko": "원천 제품명", + "summary": "원천이 쓰는 제품 이름." + }, + { + "key": "variables/oil_gasoline/unit", + "name_ko": "값 단위", + "summary": "단가는 원/L 이다." + } + ] + }, + { + "file_id": "fx", + "path": "resources/data_cost_input_value/fx_2026-08-14.json", + "name_ko": "환율", + "summary": "한국은행 ECOS 일별 환율.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables", + "name_ko": "환율", + "summary": "화폐마다 원으로 얼마인지. 키가 곧 화폐다(fx_usd·fx_jpy100…).", + "shape": "map", + "rows": 5, + "columns": [ + { + "key": "value", + "name_ko": "환율", + "unit": "원", + "visible": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true + }, + { + "key": "date", + "name_ko": "기준일", + "unit": "", + "visible": true + }, + { + "key": "source_item_code", + "name_ko": "원천 항목코드", + "unit": "", + "visible": false + } + ] + } + ], + "value_groups": [] + }, + { + "file_id": "rates", + "path": "resources/data_cost_input_value/rates_2026.json", + "name_ko": "제비율", + "summary": "법정경비·일반관리비·이윤·부가세 요율. 법이 정하고 해마다 바뀐다.", + "kind": "base_value", + "tables": [ + { + "key": "sources", + "name_ko": "출처", + "summary": "이 벌이 어느 원문을 읽어 섰는지.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "path", + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "role", + "name_ko": "구실", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/rate_environment/all_work_types", + "name_ko": "환경보전비 요율 — 공사 종류별", + "summary": "원문이 주는 공사 종류 전 줄.", + "shape": "list", + "rows": 14, + "columns": [ + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_environment/forest_road_candidates", + "name_ko": "환경보전비 요율 — 임도 후보", + "summary": "임도에 어느 줄을 쓸지 아직 안 고른 후보 둘.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_equipment_payment_guarantee/general_construction", + "name_ko": "건설기계 대여대금 지급보증 수수료율 — 종합건설", + "summary": "공사 종류마다.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_equipment_payment_guarantee/specialty_construction", + "name_ko": "건설기계 대여대금 지급보증 수수료율 — 전문건설", + "summary": "공사 종류마다.", + "shape": "list", + "rows": 5, + "columns": [ + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_goyong/brackets", + "name_ko": "고용보험료 요율", + "summary": "추정금액 등급마다 노무비에 곱하는 율.", + "shape": "list", + "rows": 8, + "columns": [ + { + "key": "grade", + "name_ko": "등급", + "unit": "", + "visible": true + }, + { + "key": "estimated_amount_bracket", + "name_ko": "추정금액 구간", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_indirect_labor/brackets", + "name_ko": "간접노무비 요율", + "summary": "직접공사비 구간 × 공사기간 × 공사 종류.", + "shape": "list", + "rows": 60, + "columns": [ + { + "key": "direct_cost_bracket", + "name_ko": "직접공사비 구간", + "unit": "", + "visible": true + }, + { + "key": "duration_bracket", + "name_ko": "공사기간 구간", + "unit": "", + "visible": true + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_other_expense/brackets", + "name_ko": "기타경비 요율", + "summary": "직접공사비 구간 × 공사기간 × 공사 종류.", + "shape": "list", + "rows": 60, + "columns": [ + { + "key": "direct_cost_bracket", + "name_ko": "직접공사비 구간", + "unit": "", + "visible": true + }, + { + "key": "duration_bracket", + "name_ko": "공사기간 구간", + "unit": "", + "visible": true + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_overhead/civil_landscape_industrial", + "name_ko": "일반관리비율 — 토목·조경·산업설비", + "summary": "추정가격 구간마다.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "estimated_price_bracket", + "name_ko": "추정가격 구간", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_overhead/specialty_electric_communication_fire_other", + "name_ko": "일반관리비율 — 전문·전기·통신·소방·기타", + "summary": "추정가격 구간마다.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "estimated_price_bracket", + "name_ko": "추정가격 구간", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_pension/annual_rates", + "name_ko": "국민연금 요율", + "summary": "연도마다 정해진 율.", + "shape": "list", + "rows": 8, + "columns": [ + { + "key": "year", + "name_ko": "연도", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_performance_guarantee_fee/brackets", + "name_ko": "공사이행보증 수수료", + "summary": "구간마다 율이 아니라 산식으로 준다.", + "shape": "list", + "rows": 5, + "columns": [ + { + "key": "direct_cost_bracket", + "name_ko": "직접공사비 구간", + "unit": "", + "visible": true + }, + { + "key": "formula", + "name_ko": "산식", + "unit": "", + "visible": true + } + ] + }, + { + "key": "variables/rate_profit/brackets", + "name_ko": "이윤율", + "summary": "추정가격 구간마다.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "estimated_price_bracket", + "name_ko": "추정가격 구간", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + }, + { + "key": "variables/rate_safety_pct/brackets", + "name_ko": "산업안전보건관리비 요율", + "summary": "대상액 구간 × 공사 종류마다 율과 기초액.", + "shape": "list", + "rows": 16, + "columns": [ + { + "key": "target_amount_bracket", + "name_ko": "대상액 구간", + "unit": "", + "visible": true + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + }, + { + "key": "base_amount_krw", + "name_ko": "기초액", + "unit": "원", + "visible": true + } + ] + }, + { + "key": "variables/rate_subcontract_payment_guarantee/brackets", + "name_ko": "하도급대금 지급보증 수수료율", + "summary": "추정가격 구간마다.", + "shape": "list", + "rows": 6, + "columns": [ + { + "key": "estimated_price_bracket", + "name_ko": "추정가격 구간", + "unit": "", + "visible": true + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "processing_rules", + "name_ko": "셈 규칙", + "summary": "요율만으로는 금액이 안 나온다 — 밑수를 어떻게 만들고 어디서 자르는지 적은 자리. 코드와 어긋나면 거울 시험이 잡는다." + }, + { + "key": "variables/rate_asbestos_contribution", + "name_ko": "석면피해구제 분담금", + "summary": "노무비에 곱하는 율." + }, + { + "key": "variables/rate_care", + "name_ko": "노인장기요양보험료", + "summary": "건강보험료 금액에 곱하는 율." + }, + { + "key": "variables/rate_environment/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_environment/forest_road_selection_status", + "name_ko": "임도 줄 고름 상태", + "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지." + }, + { + "key": "variables/rate_environment/minimum_estimated_amount_krw", + "name_ko": "적용 하한 — 추정금액", + "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + }, + { + "key": "variables/rate_equipment_payment_guarantee/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_equipment_payment_guarantee/base_note", + "name_ko": "밑수 비고", + "summary": "밑수를 그렇게 적은 까닭." + }, + { + "key": "variables/rate_goyong/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_health", + "name_ko": "건강보험료", + "summary": "직접노무비에 곱하는 율." + }, + { + "key": "variables/rate_indirect_labor/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_other_expense/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_overhead/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_pension/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_pension/rate_from_2033_percent", + "name_ko": "2033년 이후 요율", + "summary": "표 밖으로 나가는 해부터 쓰는 율(%)." + }, + { + "key": "variables/rate_performance_guarantee_fee/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", + "name_ko": "임도에 걸리는지", + "summary": "보통 임도 규모에서 이 항목이 서는지." + }, + { + "key": "variables/rate_profit/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_retirement_mutual_aid", + "name_ko": "퇴직공제부금비", + "summary": "직접노무비에 곱하는 율. 추정금액 하한이 있다." + }, + { + "key": "variables/rate_safety_base", + "name_ko": "산업안전보건관리비 기초액", + "summary": "요율표 안에 함께 든 기초액이 걸리는 구간." + }, + { + "key": "variables/rate_safety_pct/base_rule", + "name_ko": "밑수 규칙", + "summary": "밑수를 고르는 방식." + }, + { + "key": "variables/rate_safety_pct/base_with_owner_supplied_material", + "name_ko": "대상액 — 관급자재 있을 때", + "summary": "고시가 준 두 식 가운데 작은 쪽." + }, + { + "key": "variables/rate_safety_pct/base_without_owner_supplied_material", + "name_ko": "대상액 — 관급자재 없을 때", + "summary": "재료비 + 직접노무비." + }, + { + "key": "variables/rate_safety_pct/manager_thresholds", + "name_ko": "안전관리자 선임 기준액", + "summary": "안전관리자를 둬야 하는 금액 문턱." + }, + { + "key": "variables/rate_safety_pct/minimum_total_construction_amount_krw", + "name_ko": "적용 하한 — 총공사금액", + "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + }, + { + "key": "variables/rate_sanjae", + "name_ko": "산재보험료", + "summary": "노무비에 곱하는 율." + }, + { + "key": "variables/rate_subcontract_payment_guarantee/base", + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + { + "key": "variables/rate_vat", + "name_ko": "부가가치세", + "summary": "공급가액에 곱하는 율." + }, + { + "key": "variables/rate_wage_claim_contribution", + "name_ko": "임금채권보장 부담금", + "summary": "노무비에 곱하는 율." + } + ] + }, + { + "file_id": "basis_missing", + "path": "resources/data_work_item_master/basis_missing_2026-01-01.json", + "name_ko": "밑수 못 찾은 표", + "summary": "「10㎡당」 같은 기준 수량을 못 읽은 품셈 표 기록. 정본이 아니다.", + "kind": "byproduct", + "tables": [ + { + "key": "items", + "name_ko": "밑수 못 찾은 표", + "summary": "기준 수량을 못 읽은 품셈 표. 1 단위당으로 단정하면 곱셈이 틀린다.", + "shape": "list", + "rows": 115, + "columns": [ + { + "key": "pum_table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "pum_form", + "name_ko": "품셈 표 형태", + "unit": "", + "visible": true + }, + { + "key": "line", + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "form_undetermined", + "path": "resources/data_work_item_master/form_undetermined_2026-01-01.json", + "name_ko": "형태 못 정한 표", + "summary": "품·소요량·계수 어느 형태인지 못 정한 품셈 표 기록. 정본이 아니다.", + "kind": "byproduct", + "tables": [ + { + "key": "items", + "name_ko": "형태 못 정한 표", + "summary": "품·소요량·계수 어느 형태인지 사람이 봐야 하는 표.", + "shape": "list", + "rows": 14, + "columns": [ + { + "key": "pum_table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "section", + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + { + "key": "headers", + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + { + "key": "first_rows", + "name_ko": "첫 줄들", + "unit": "", + "visible": true + }, + { + "key": "reason", + "name_ko": "사유", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "resource_axis", + "path": "resources/data_cost_resource_axis/resource_axis_2026-01-01.json", + "name_ko": "공종별 자원 소요량(자원 축)", + "summary": "품셈 표에서 뽑아 낸 자원 줄. 정본은 품셈 원문이고 이것은 기록이다.", + "kind": "byproduct", + "tables": [ + { + "key": "rows", + "name_ko": "공종별 자원 줄", + "summary": "공종 하나에 드는 자원(품·기계) 한 줄씩. 품셈 표에서 뽑아 낸 기록이다.", + "shape": "list", + "rows": 418, + "columns": [ + { + "key": "alternative_amount", + "name_ko": "조건 시공 시 소요량", + "unit": "", + "visible": true + }, + { + "key": "amount", + "name_ko": "소요량", + "unit": "", + "visible": true + }, + { + "key": "amount_unit", + "name_ko": "소요량 단위", + "unit": "", + "visible": true + }, + { + "key": "group_ratio_pct", + "name_ko": "분류 딱지 배분율", + "unit": "%", + "visible": true + }, + { + "key": "pum_form", + "name_ko": "품셈 표 형태", + "unit": "", + "visible": true + }, + { + "key": "pum_table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "raw_row_index", + "name_ko": "원문 줄 차례", + "unit": "", + "visible": false + }, + { + "key": "resource_code", + "name_ko": "자원코드", + "unit": "", + "visible": true + }, + { + "key": "resource_kind", + "name_ko": "자원 갈래", + "unit": "", + "visible": true + }, + { + "key": "resource_name", + "name_ko": "자원명", + "unit": "", + "visible": true + }, + { + "key": "resource_spec", + "name_ko": "자원 규격", + "unit": "", + "visible": true + }, + { + "key": "variant", + "name_ko": "갈래", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + { + "key": "stats", + "name_ko": "집계", + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + } + ] + }, + { + "file_id": "unmatched", + "path": "resources/data_cost_resource_axis/unmatched_2026-01-01.json", + "name_ko": "못 맞춘 자원 이름", + "summary": "카탈로그에서 못 찾은 자원 이름 기록. 빈칸으로 두지 않으려고 모은다.", + "kind": "byproduct", + "tables": [ + { + "key": "rows", + "name_ko": "못 맞춘 줄", + "summary": "카탈로그에서 이름을 못 찾은 자원 줄과 그 사유.", + "shape": "list", + "rows": 497, + "columns": [ + { + "key": "cell", + "name_ko": "원문 칸", + "unit": "", + "visible": true + }, + { + "key": "pum_table_id", + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + { + "key": "reason", + "name_ko": "사유", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + } + ] + }, + { + "file_id": "masonry_wet", + "path": "resources/library_structure/masonry_wet.json", + "name_ko": "돌쌓기(찰) 구조물 양식", + "summary": "구조물도 양식형 항목 첫 벌. 제원 칸·계수표·수량 줄·일위대가 틀이 한 벌로 있다.", + "kind": "seed", + "tables": [ + { + "key": "rows", + "name_ko": "수량 산출 줄", + "summary": "식 한 줄이 물량 하나. 갈 곳(일위대가·자재·참고)까지 줄마다 적는다.", + "shape": "list", + "rows": 15, + "columns": [ + { + "key": "seq", + "name_ko": "차례", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "줄 이름", + "unit": "", + "visible": true + }, + { + "key": "spec", + "name_ko": "규격", + "unit": "", + "visible": true + }, + { + "key": "formula", + "name_ko": "산식", + "unit": "", + "visible": true + }, + { + "key": "formula_text", + "name_ko": "식 풀이", + "unit": "", + "visible": true + }, + { + "key": "unit", + "name_ko": "수량 단위", + "unit": "", + "visible": true + }, + { + "key": "destination", + "name_ko": "갈 곳", + "unit": "", + "visible": true + }, + { + "key": "rounding", + "name_ko": "반올림", + "unit": "", + "visible": true + }, + { + "key": "source", + "name_ko": "값이 오는 자리", + "unit": "", + "visible": true + }, + { + "key": "refs", + "name_ko": "참조 줄", + "unit": "", + "visible": true + }, + { + "key": "when", + "name_ko": "서는 조건", + "unit": "", + "visible": true + } + ] + }, + { + "key": "tables", + "name_ko": "계수표", + "summary": "뒷길이·돌 종류로 찾아 쓰는 원단위표 세 벌(고임돌·채움 콘크리트·돌 중량).", + "shape": "map", + "rows": 3, + "columns": [ + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "keys", + "name_ko": "찾는 키", + "unit": "", + "visible": true + }, + { + "key": "columns", + "name_ko": "열", + "unit": "", + "visible": true + } + ] + }, + { + "key": "unit_price/rows", + "name_ko": "일위대가 줄", + "summary": "위 수량 줄 가운데 일위대가로 가는 것과 그 공종코드.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "seq", + "name_ko": "차례", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "줄 이름", + "unit": "", + "visible": true + }, + { + "key": "from_row", + "name_ko": "가져오는 수량 줄", + "unit": "", + "visible": true + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + { + "key": "variant_from", + "name_ko": "갈래 원본 칸", + "unit": "", + "visible": true + } + ] + }, + { + "key": "vars", + "name_ko": "제원 칸", + "summary": "사용자가 넣거나 고르는 값. 여기 값이 바뀌면 아래 수량 줄이 다시 선다.", + "shape": "map", + "rows": 15, + "columns": [ + { + "key": "label", + "name_ko": "칸 이름", + "unit": "", + "visible": true + }, + { + "key": "source", + "name_ko": "값이 오는 자리", + "unit": "", + "visible": true + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true + }, + { + "key": "option", + "name_ko": "고르개 키", + "unit": "", + "visible": true + }, + { + "key": "default", + "name_ko": "기본값", + "unit": "", + "visible": true + }, + { + "key": "default_from", + "name_ko": "기본값이 오는 자리", + "unit": "", + "visible": true + }, + { + "key": "candidates", + "name_ko": "후보", + "unit": "", + "visible": true + }, + { + "key": "setting", + "name_ko": "프로젝트 설정 키", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "code", + "name_ko": "항목 코드", + "summary": "라이브러리 항목 코드." + }, + { + "key": "differs_from_engine", + "name_ko": "전개와 다른 자리", + "summary": "지금 돌아가는 전개와 이 양식이 갈리는 곳을 적어 둔 자리." + }, + { + "key": "item_kind", + "name_ko": "항목 갈래", + "summary": "양식형(form)인지 고정형인지." + }, + { + "key": "library_tier", + "name_ko": "라이브러리 단", + "summary": "프로그램·회사·개인 가운데 어느 단의 항목인지." + }, + { + "key": "name", + "name_ko": "항목 이름", + "summary": "화면에 보이는 이름." + }, + { + "key": "note", + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "summary": "어느 구조물 종류에 붙는 양식인지." + }, + { + "key": "unit_price/note", + "name_ko": "일위대가 틀 설명", + "summary": "일위대가 줄을 어떻게 세우는지 적은 글." + } + ] + }, + { + "file_id": "manifest_cost_input_value", + "path": "resources/data_cost_input_value/_manifest.json", + "name_ko": "원가 입력변수 — 파일 장부", + "summary": "실린 파일·적용일·지문 목록. 브레인 갈래표 31 밖의 장부다.", + "kind": "byproduct", + "tables": [ + { + "key": "deferred", + "name_ko": "미확보 변수", + "summary": "원천을 아직 못 받아 비워 둔 변수와 그 사유.", + "shape": "list", + "rows": 4, + "columns": [ + { + "key": "variable", + "name_ko": "변수 키", + "unit": "", + "visible": true + }, + { + "key": "reason", + "name_ko": "사유", + "unit": "", + "visible": true + }, + { + "key": "source_group", + "name_ko": "원천 묶음", + "unit": "", + "visible": true + }, + { + "key": "source_needed", + "name_ko": "있어야 할 원천", + "unit": "", + "visible": true + }, + { + "key": "knowledge_ref", + "name_ko": "지식DB 자리", + "unit": "", + "visible": false + } + ] + }, + { + "key": "files", + "name_ko": "실린 파일", + "summary": "이 폴더에 든 파일과 적용 시작일·지문.", + "shape": "list", + "rows": 11, + "columns": [ + { + "key": "file", + "name_ko": "파일 이름", + "unit": "", + "visible": true + }, + { + "key": "dataset_id", + "name_ko": "자료 키", + "unit": "", + "visible": true + }, + { + "key": "effective_date", + "name_ko": "적용 시작일", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "size_bytes", + "name_ko": "파일 크기", + "unit": "byte", + "visible": false + } + ] + }, + { + "key": "program_entry", + "name_ko": "프로그램 입력 몫", + "summary": "자료로 못 받고 사용자나 프로그램이 넣어야 하는 원천.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "source_group", + "name_ko": "원천 묶음", + "unit": "", + "visible": true + }, + { + "key": "responsibility", + "name_ko": "맡는 자리", + "unit": "", + "visible": true + } + ] + }, + { + "key": "selection_pending", + "name_ko": "채택 대기", + "summary": "원문이 여러 줄을 주어 어느 것을 쓸지 아직 안 고른 자리.", + "shape": "list", + "rows": 2, + "columns": [ + { + "key": "variable", + "name_ko": "변수 키", + "unit": "", + "visible": true + }, + { + "key": "detail", + "name_ko": "내용", + "unit": "", + "visible": true + }, + { + "key": "candidates", + "name_ko": "후보", + "unit": "", + "visible": true + } + ] + } + ], + "value_groups": [ + { + "key": "policy", + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + } + ] + }, + { + "file_id": "manifest_material_surcharge", + "path": "resources/data_material_surcharge/_manifest.json", + "name_ko": "재료 할증률 — 파일 장부", + "summary": "실린 파일·지문 목록. 브레인 갈래표 31 밖의 장부다.", + "kind": "byproduct", + "tables": [ + { + "key": "files", + "name_ko": "실린 파일", + "summary": "이 폴더에 든 파일과 지문.", + "shape": "list", + "rows": 1, + "columns": [ + { + "key": "file", + "name_ko": "파일 이름", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "size_bytes", + "name_ko": "파일 크기", + "unit": "byte", + "visible": false + } + ] + } + ], + "value_groups": [ + { + "key": "built_by", + "name_ko": "만든 자리", + "summary": "이 벌을 지은 코드나 사람." + } + ] + }, + { + "file_id": "manifest_work_item_master", + "path": "resources/data_work_item_master/_manifest.json", + "name_ko": "공종 마스터 — 파일 장부", + "summary": "실린 파일·지문 목록. 브레인 갈래표 31 밖의 장부다.", + "kind": "byproduct", + "tables": [ + { + "key": "files", + "name_ko": "실린 파일", + "summary": "이 폴더에 든 파일과 지문.", + "shape": "list", + "rows": 3, + "columns": [ + { + "key": "file", + "name_ko": "파일 이름", + "unit": "", + "visible": true + }, + { + "key": "sha256", + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + { + "key": "size_bytes", + "name_ko": "파일 크기", + "unit": "byte", + "visible": false + } + ] + } + ], + "value_groups": [ + { + "key": "built_by", + "name_ko": "만든 자리", + "summary": "이 벌을 지은 코드나 사람." + }, + { + "key": "source", + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + } + ] + } + ], + "columns": { + "about": { + "name_ko": "무엇에 대한 것인가", + "unit": "", + "visible": true + }, + "alias": { + "name_ko": "별칭 키", + "unit": "", + "visible": true + }, + "alt_condition": { + "name_ko": "다른 조건", + "unit": "", + "visible": true + }, + "alt_rate": { + "name_ko": "다른 조건 할증률", + "unit": "%", + "visible": true + }, + "alternative_amount": { + "name_ko": "조건 시공 시 소요량", + "unit": "", + "visible": true + }, + "amount": { + "name_ko": "소요량", + "unit": "", + "visible": true + }, + "amount_unit": { + "name_ko": "소요량 단위", + "unit": "", + "visible": true + }, + "annual_management_ratio": { + "name_ko": "연간관리비율", + "unit": "", + "visible": true + }, + "annual_standard_hours": { + "name_ko": "연간표준가동시간", + "unit": "시간", + "visible": true + }, + "axis": { + "name_ko": "축", + "unit": "", + "visible": true + }, + "base_amount_krw": { + "name_ko": "기초액", + "unit": "원", + "visible": true + }, + "basis": { + "name_ko": "근거", + "unit": "", + "visible": true + }, + "basis_source": { + "name_ko": "밑수 근거", + "unit": "", + "visible": true + }, + "basis_unit": { + "name_ko": "밑수 단위", + "unit": "", + "visible": true + }, + "basis_unit_note": { + "name_ko": "밑수 비고", + "unit": "", + "visible": true + }, + "billing_component": { + "name_ko": "내역 성분", + "unit": "", + "visible": true + }, + "billing_note": { + "name_ko": "내역 비고", + "unit": "", + "visible": true + }, + "bond_codes": { + "name_ko": "쌓기 방식별 공종코드", + "unit": "", + "visible": true + }, + "boulder_mixed_soil": { + "name_ko": "호박돌 섞인 토사 할증", + "unit": "%", + "visible": true + }, + "business_division_code": { + "name_ko": "사업구분 코드", + "unit": "", + "visible": false + }, + "business_division_name": { + "name_ko": "사업구분", + "unit": "", + "visible": true + }, + "candidates": { + "name_ko": "후보", + "unit": "", + "visible": true + }, + "carpenter": { + "name_ko": "건축목공", + "unit": "인/㎥", + "visible": true + }, + "cell": { + "name_ko": "원문 칸", + "unit": "", + "visible": true + }, + "class": { + "name_ko": "갈래", + "unit": "", + "visible": true + }, + "class_from": { + "name_ko": "갈래 원본 칸", + "unit": "", + "visible": true + }, + "class_note": { + "name_ko": "갈래 비고", + "unit": "", + "visible": true + }, + "classification_code": { + "name_ko": "물품분류번호", + "unit": "", + "visible": true + }, + "classification_name": { + "name_ko": "물품분류명", + "unit": "", + "visible": true + }, + "code": { + "name_ko": "자원코드", + "unit": "", + "visible": true + }, + "columns": { + "name_ko": "열", + "unit": "", + "visible": true + }, + "compare": { + "name_ko": "견줌", + "unit": "", + "visible": true + }, + "components": { + "name_ko": "구성 물량", + "unit": "", + "visible": true + }, + "condition": { + "name_ko": "조건", + "unit": "", + "visible": true + }, + "daily_area_m2": { + "name_ko": "1일 시공량", + "unit": "㎡", + "visible": true + }, + "daily_wage_krw": { + "name_ko": "일 노임", + "unit": "원", + "visible": true + }, + "dataset_id": { + "name_ko": "자료 키", + "unit": "", + "visible": true + }, + "date": { + "name_ko": "기준일", + "unit": "", + "visible": true + }, + "default": { + "name_ko": "기본값", + "unit": "", + "visible": true + }, + "default_from": { + "name_ko": "기본값이 오는 자리", + "unit": "", + "visible": true + }, + "delivery_condition": { + "name_ko": "납품 조건", + "unit": "", + "visible": true + }, + "depreciation_coefficient_1e_minus_7": { + "name_ko": "원문 상각비계수", + "unit": "10^-7", + "visible": false + }, + "depreciation_ratio": { + "name_ko": "상각비율", + "unit": "", + "visible": true + }, + "destination": { + "name_ko": "갈 곳", + "unit": "", + "visible": true + }, + "detail": { + "name_ko": "내용", + "unit": "", + "visible": true + }, + "direct_cost_bracket": { + "name_ko": "직접공사비 구간", + "unit": "", + "visible": true + }, + "doc": { + "name_ko": "문서", + "unit": "", + "visible": true + }, + "duplicate_group": { + "name_ko": "원문 중복 묶음", + "unit": "", + "visible": true + }, + "duplicate_index": { + "name_ko": "중복 차례", + "unit": "", + "visible": true + }, + "duration_bracket": { + "name_ko": "공사기간 구간", + "unit": "", + "visible": true + }, + "economic_life_hours": { + "name_ko": "내용시간", + "unit": "시간", + "visible": true + }, + "effect": { + "name_ko": "바뀌면 생기는 차이", + "unit": "", + "visible": true + }, + "effective_date": { + "name_ko": "적용 시작일", + "unit": "", + "visible": true + }, + "entries": { + "name_ko": "줄 묶음", + "unit": "", + "visible": true + }, + "equipment": { + "name_ko": "운반 수단", + "unit": "", + "visible": true + }, + "estimated_amount_bracket": { + "name_ko": "추정금액 구간", + "unit": "", + "visible": true + }, + "estimated_price_bracket": { + "name_ko": "추정가격 구간", + "unit": "", + "visible": true + }, + "examples": { + "name_ko": "원문 예시", + "unit": "", + "visible": true + }, + "exception": { + "name_ko": "예외", + "unit": "", + "visible": true + }, + "exclusion": { + "name_ko": "제외", + "unit": "", + "visible": true + }, + "field": { + "name_ko": "분야", + "unit": "", + "visible": true + }, + "file": { + "name_ko": "파일 이름", + "unit": "", + "visible": true + }, + "first_rows": { + "name_ko": "첫 줄들", + "unit": "", + "visible": true + }, + "form": { + "name_ko": "형식", + "unit": "", + "visible": true + }, + "form_codes": { + "name_ko": "형식별 공종코드", + "unit": "", + "visible": true + }, + "formula": { + "name_ko": "산식", + "unit": "", + "visible": true + }, + "formula_text": { + "name_ko": "식 풀이", + "unit": "", + "visible": true + }, + "from": { + "name_ko": "원래 이름", + "unit": "", + "visible": true + }, + "from_row": { + "name_ko": "가져오는 수량 줄", + "unit": "", + "visible": true + }, + "fuel_kind": { + "name_ko": "연료 종류", + "unit": "", + "visible": true + }, + "fuel_liters_per_hour": { + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true + }, + "fuel_rate_l_per_hour": { + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true + }, + "fuel_type": { + "name_ko": "연료 종류", + "unit": "", + "visible": true + }, + "grade": { + "name_ko": "등급", + "unit": "", + "visible": true + }, + "ground": { + "name_ko": "지반 갈래", + "unit": "", + "visible": true + }, + "group": { + "name_ko": "묶음", + "unit": "", + "visible": true + }, + "group_ratio_pct": { + "name_ko": "분류 딱지 배분율", + "unit": "%", + "visible": true + }, + "guard": { + "name_ko": "가드", + "unit": "", + "visible": true + }, + "headers": { + "name_ko": "표 머리", + "unit": "", + "visible": true + }, + "hours_per_day": { + "name_ko": "1일 근로시간", + "unit": "시간", + "visible": true + }, + "in_bill": { + "name_ko": "내역에 싣나", + "unit": "", + "visible": true + }, + "industry": { + "name_ko": "업종", + "unit": "", + "visible": true + }, + "item": { + "name_ko": "항목", + "unit": "", + "visible": true + }, + "item_code": { + "name_ko": "물품코드", + "unit": "", + "visible": true + }, + "items": { + "name_ko": "항목 묶음", + "unit": "", + "visible": true + }, + "key": { + "name_ko": "키", + "unit": "", + "visible": true + }, + "keys": { + "name_ko": "찾는 키", + "unit": "", + "visible": true + }, + "kind": { + "name_ko": "갈래", + "unit": "", + "visible": true + }, + "knowledge_ref": { + "name_ko": "지식DB 자리", + "unit": "", + "visible": false + }, + "label": { + "name_ko": "칸 이름", + "unit": "", + "visible": true + }, + "laborer": { + "name_ko": "보통인부", + "unit": "인/㎥", + "visible": true + }, + "leaf_codes": { + "name_ko": "잎 코드", + "unit": "", + "visible": true + }, + "leaf_from": { + "name_ko": "잎 원본 칸", + "unit": "", + "visible": true + }, + "leaf_note": { + "name_ko": "잎 비고", + "unit": "", + "visible": true + }, + "level": { + "name_ko": "단계", + "unit": "", + "visible": true + }, + "line": { + "name_ko": "원문 줄 번호", + "unit": "", + "visible": false + }, + "listed_condition": { + "name_ko": "원문이 못박은 조건", + "unit": "", + "visible": true + }, + "listed_in": { + "name_ko": "실린 자리", + "unit": "", + "visible": true + }, + "loss_coefficient_per_hour": { + "name_ko": "시간당 손료계수", + "unit": "", + "visible": true + }, + "machine_code": { + "name_ko": "기계코드", + "unit": "", + "visible": true + }, + "machine_group": { + "name_ko": "기계 묶음", + "unit": "", + "visible": true + }, + "machine_name": { + "name_ko": "기계명", + "unit": "", + "visible": true + }, + "maintenance_coefficient_1e_minus_7": { + "name_ko": "원문 정비비계수", + "unit": "10^-7", + "visible": false + }, + "maintenance_ratio": { + "name_ko": "정비비율", + "unit": "", + "visible": true + }, + "management_coefficient_1e_minus_7": { + "name_ko": "원문 관리비계수", + "unit": "10^-7", + "visible": false + }, + "master_name": { + "name_ko": "마스터 공종명", + "unit": "", + "visible": true + }, + "matched": { + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + "matched_example": { + "name_ko": "걸린 원문 예시", + "unit": "", + "visible": true + }, + "material": { + "name_ko": "자재명", + "unit": "", + "visible": true + }, + "max": { + "name_ko": "상한", + "unit": "", + "visible": true + }, + "max_cm": { + "name_ko": "상한", + "unit": "㎝", + "visible": true + }, + "min": { + "name_ko": "하한", + "unit": "", + "visible": true + }, + "misc_material_percent": { + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true + }, + "misc_material_percent_of_fuel": { + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true + }, + "mismatch_kind": { + "name_ko": "어긋남 갈래", + "unit": "", + "visible": true + }, + "mismatch_reason": { + "name_ko": "어긋남 사유", + "unit": "", + "visible": true + }, + "name": { + "name_ko": "이름", + "unit": "", + "visible": true + }, + "needs": { + "name_ko": "있어야 열리는 것", + "unit": "", + "visible": true + }, + "note": { + "name_ko": "비고", + "unit": "", + "visible": true + }, + "notes": { + "name_ko": "비고 묶음", + "unit": "", + "visible": true + }, + "notice_datetime": { + "name_ko": "공고 일시", + "unit": "", + "visible": true + }, + "notice_number": { + "name_ko": "공고번호", + "unit": "", + "visible": false + }, + "number": { + "name_ko": "품셈 번호", + "unit": "", + "visible": true + }, + "observed": { + "name_ko": "관측값", + "unit": "", + "visible": true + }, + "occupation_code": { + "name_ko": "직종코드", + "unit": "", + "visible": true + }, + "occupation_name": { + "name_ko": "직종명", + "unit": "", + "visible": true + }, + "operator_mapping_is_provisional": { + "name_ko": "운전원 매칭 잠정 여부", + "unit": "", + "visible": true + }, + "operator_occupation_code": { + "name_ko": "운전원 직종코드", + "unit": "", + "visible": true + }, + "operator_person_days": { + "name_ko": "운전원", + "unit": "인/일", + "visible": true + }, + "operator_person_per_day": { + "name_ko": "운전원", + "unit": "인/일", + "visible": true + }, + "option": { + "name_ko": "고르개 키", + "unit": "", + "visible": true + }, + "our_choice": { + "name_ko": "우리가 고른 쪽", + "unit": "", + "visible": true + }, + "our_usage": { + "name_ko": "우리 쓰임", + "unit": "", + "visible": true + }, + "outside_note": { + "name_ko": "묶음 밖 비고", + "unit": "", + "visible": true + }, + "parent_code": { + "name_ko": "상위 공종코드", + "unit": "", + "visible": true + }, + "parent_mode": { + "name_ko": "하위 고르는 방식", + "unit": "", + "visible": true + }, + "parse_method": { + "name_ko": "읽은 방법", + "unit": "", + "visible": false + }, + "parts": { + "name_ko": "조각", + "unit": "", + "visible": true + }, + "parts_note": { + "name_ko": "조각 비고", + "unit": "", + "visible": true + }, + "path": { + "name_ko": "원문 자리", + "unit": "", + "visible": true + }, + "payload_density_note": { + "name_ko": "적재 단위중량 비고", + "unit": "", + "visible": true + }, + "placing_note": { + "name_ko": "타설 비고", + "unit": "", + "visible": true + }, + "price_krw": { + "name_ko": "단가", + "unit": "원", + "visible": true + }, + "price_thousand_krw": { + "name_ko": "취득가", + "unit": "천원", + "visible": true + }, + "price_type": { + "name_ko": "가격 종류", + "unit": "", + "visible": true + }, + "provisional": { + "name_ko": "잠정 여부", + "unit": "", + "visible": true + }, + "pum_edition": { + "name_ko": "품셈 판", + "unit": "", + "visible": true + }, + "pum_form": { + "name_ko": "품셈 표 형태", + "unit": "", + "visible": true + }, + "pum_table_id": { + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + "pumsem": { + "name_ko": "어느 품셈", + "unit": "", + "visible": true + }, + "quote": { + "name_ko": "원문 인용", + "unit": "", + "visible": true + }, + "rate": { + "name_ko": "할증률", + "unit": "%", + "visible": true + }, + "rate_percent": { + "name_ko": "요율", + "unit": "%", + "visible": true + }, + "raw_row_index": { + "name_ko": "원문 줄 차례", + "unit": "", + "visible": false + }, + "reason": { + "name_ko": "사유", + "unit": "", + "visible": true + }, + "refs": { + "name_ko": "참조 줄", + "unit": "", + "visible": true + }, + "reliability": { + "name_ko": "신뢰도 기호", + "unit": "", + "visible": true + }, + "resource_code": { + "name_ko": "자원코드", + "unit": "", + "visible": true + }, + "resource_kind": { + "name_ko": "자원 갈래", + "unit": "", + "visible": true + }, + "resource_name": { + "name_ko": "자원명", + "unit": "", + "visible": true + }, + "resource_spec": { + "name_ko": "자원 규격", + "unit": "", + "visible": true + }, + "responsibility": { + "name_ko": "맡는 자리", + "unit": "", + "visible": true + }, + "reuse_count": { + "name_ko": "사용횟수", + "unit": "회", + "visible": true + }, + "rock_work": { + "name_ko": "암 작업 할증", + "unit": "%", + "visible": true + }, + "role": { + "name_ko": "구실", + "unit": "", + "visible": true + }, + "rounding": { + "name_ko": "반올림", + "unit": "", + "visible": true + }, + "rows": { + "name_ko": "표 줄", + "unit": "", + "visible": true + }, + "rule": { + "name_ko": "별도 규정", + "unit": "", + "visible": true + }, + "rule_id": { + "name_ko": "규칙 키", + "unit": "", + "visible": true + }, + "scope": { + "name_ko": "걸치는 범위", + "unit": "", + "visible": true + }, + "secondary_axes": { + "name_ko": "둘째 갈래 축", + "unit": "", + "visible": true + }, + "secondary_axes_note": { + "name_ko": "둘째 갈래 비고", + "unit": "", + "visible": true + }, + "section": { + "name_ko": "품셈 절", + "unit": "", + "visible": true + }, + "selection": { + "name_ko": "채택 여부", + "unit": "", + "visible": true + }, + "seq": { + "name_ko": "차례", + "unit": "", + "visible": true + }, + "setting": { + "name_ko": "프로젝트 설정 키", + "unit": "", + "visible": true + }, + "sha256": { + "name_ko": "파일 지문(SHA-256)", + "unit": "", + "visible": false + }, + "sido_code": { + "name_ko": "시도코드", + "unit": "", + "visible": true + }, + "sido_name": { + "name_ko": "시도명", + "unit": "", + "visible": true + }, + "size_bytes": { + "name_ko": "파일 크기", + "unit": "byte", + "visible": false + }, + "soil_type": { + "name_ko": "토질", + "unit": "", + "visible": true + }, + "sort_order": { + "name_ko": "정렬 차례", + "unit": "", + "visible": false + }, + "source": { + "name_ko": "출처", + "unit": "", + "visible": true + }, + "source_coefficient_1e_minus_7": { + "name_ko": "원문 손료계수", + "unit": "10^-7", + "visible": false + }, + "source_file": { + "name_ko": "원문 파일", + "unit": "", + "visible": false + }, + "source_flag": { + "name_ko": "원문 표시", + "unit": "", + "visible": true + }, + "source_group": { + "name_ko": "원천 묶음", + "unit": "", + "visible": true + }, + "source_item_code": { + "name_ko": "원천 항목코드", + "unit": "", + "visible": false + }, + "source_needed": { + "name_ko": "있어야 할 원천", + "unit": "", + "visible": true + }, + "source_note": { + "name_ko": "원천 비고", + "unit": "", + "visible": true + }, + "source_product_code": { + "name_ko": "원천 제품코드", + "unit": "", + "visible": false + }, + "source_product_name": { + "name_ko": "원천 제품명", + "unit": "", + "visible": true + }, + "source_section": { + "name_ko": "원문 절", + "unit": "", + "visible": true + }, + "sources": { + "name_ko": "출처", + "unit": "", + "visible": true + }, + "spec": { + "name_ko": "규격", + "unit": "", + "visible": true + }, + "specification": { + "name_ko": "규격", + "unit": "", + "visible": true + }, + "status": { + "name_ko": "공표 상태", + "unit": "", + "visible": true + }, + "steps": { + "name_ko": "단계 합산 구성", + "unit": "", + "visible": true + }, + "steps_basis": { + "name_ko": "단계 합산 근거", + "unit": "", + "visible": true + }, + "table_id": { + "name_ko": "품셈 표 번호", + "unit": "", + "visible": true + }, + "tables": { + "name_ko": "딸린 품셈 표", + "unit": "", + "visible": true + }, + "target_amount_bracket": { + "name_ko": "대상액 구간", + "unit": "", + "visible": true + }, + "to": { + "name_ko": "이을 코드", + "unit": "", + "visible": true + }, + "type_id": { + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true + }, + "unit": { + "name_ko": "단위", + "unit": "", + "visible": true + }, + "value": { + "name_ko": "값", + "unit": "", + "visible": true + }, + "variable": { + "name_ko": "변수 키", + "unit": "", + "visible": true + }, + "variant": { + "name_ko": "갈래", + "unit": "", + "visible": true + }, + "variant_axis": { + "name_ko": "갈래 축", + "unit": "", + "visible": true + }, + "variant_from": { + "name_ko": "갈래 원본 칸", + "unit": "", + "visible": true + }, + "variant_keys": { + "name_ko": "갈래 키", + "unit": "", + "visible": true + }, + "variant_missing_reason": { + "name_ko": "갈래 미선택 사유", + "unit": "", + "visible": true + }, + "variant_note": { + "name_ko": "갈래 비고", + "unit": "", + "visible": true + }, + "variant_template": { + "name_ko": "갈래 틀", + "unit": "", + "visible": true + }, + "variant_value": { + "name_ko": "갈래 값", + "unit": "", + "visible": true + }, + "variation_coefficient": { + "name_ko": "변동계수", + "unit": "%", + "visible": true + }, + "vat_basis": { + "name_ko": "부가세 기준", + "unit": "", + "visible": true + }, + "via": { + "name_ko": "거쳐 온 자리", + "unit": "", + "visible": true + }, + "when": { + "name_ko": "서는 조건", + "unit": "", + "visible": true + }, + "where": { + "name_ko": "어디서 갈리나", + "unit": "", + "visible": true + }, + "why": { + "name_ko": "까닭", + "unit": "", + "visible": true + }, + "why_not_applied": { + "name_ko": "안 쓰는 사유", + "unit": "", + "visible": true + }, + "work_item_code": { + "name_ko": "공종코드", + "unit": "", + "visible": true + }, + "work_type": { + "name_ko": "공사 종류", + "unit": "", + "visible": true + }, + "year": { + "name_ko": "연도", + "unit": "", + "visible": true + }, + "성토": { + "name_ko": "성토부 경사", + "unit": "1:n", + "visible": true + }, + "절토": { + "name_ko": "절토부 경사", + "unit": "1:n", + "visible": true + } + }, + "column_overrides": { + "coef/max": { + "name_ko": "계수 상한", + "unit": "", + "visible": true + }, + "coef/min": { + "name_ko": "계수 하한", + "unit": "", + "visible": true + }, + "fx/value": { + "name_ko": "환율", + "unit": "원", + "visible": true + }, + "masonry_wet/name": { + "name_ko": "줄 이름", + "unit": "", + "visible": true + }, + "masonry_wet/source": { + "name_ko": "값이 오는 자리", + "unit": "", + "visible": true + }, + "masonry_wet/unit": { + "name_ko": "수량 단위", + "unit": "", + "visible": true + }, + "oil/scope": { + "name_ko": "범위", + "unit": "", + "visible": true + }, + "oil/value": { + "name_ko": "단가", + "unit": "원/L", + "visible": true + }, + "oil_regional/value": { + "name_ko": "단가", + "unit": "원/L", + "visible": true + }, + "resource_catalog_ext/source": { + "name_ko": "어디서 왔나", + "unit": "", + "visible": true + }, + "structure_unit_observed/key": { + "name_ko": "규칙·물음 키", + "unit": "", + "visible": true + }, + "structure_unit_observed/section": { + "name_ko": "원본 절", + "unit": "", + "visible": true + }, + "structure_unit_observed/source": { + "name_ko": "원천", + "unit": "", + "visible": true + }, + "structure_unit_observed/spec": { + "name_ko": "제원", + "unit": "", + "visible": true + } + }, + "value_keys": { + "application_status": { + "name_ko": "적용 상태", + "summary": "쓰려면 무엇이 더 정해져야 하는지." + }, + "base": { + "name_ko": "밑수", + "summary": "이 요율을 곱하는 대상." + }, + "base_note": { + "name_ko": "밑수 비고", + "summary": "밑수를 그렇게 적은 까닭." + }, + "base_rule": { + "name_ko": "밑수 규칙", + "summary": "밑수를 고르는 방식." + }, + "base_with_owner_supplied_material": { + "name_ko": "대상액 — 관급자재 있을 때", + "summary": "고시가 준 두 식 가운데 작은 쪽." + }, + "base_without_owner_supplied_material": { + "name_ko": "대상액 — 관급자재 없을 때", + "summary": "재료비 + 직접노무비." + }, + "basis_unit": { + "name_ko": "밑수", + "summary": "값이 무엇 하나당인지." + }, + "built_by": { + "name_ko": "만든 자리", + "summary": "이 벌을 지은 코드나 사람." + }, + "date": { + "name_ko": "기준일", + "summary": "값이 선 날." + }, + "derived_from": { + "name_ko": "무엇에서 나왔나", + "summary": "어느 벌을 풀어 만든 파생본인지." + }, + "duplicate_application_prohibited": { + "name_ko": "중복 적용 금지", + "summary": "두 자리에 걸면 두 번 세게 된다는 표시." + }, + "duplicate_note": { + "name_ko": "원문 중복 비고", + "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리." + }, + "fallback": { + "name_ko": "미지정일 때", + "summary": "사용자가 안 고른 경우 쓰는 한 벌." + }, + "forest_road_selection_status": { + "name_ko": "임도 줄 고름 상태", + "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지." + }, + "items": { + "name_ko": "항목", + "summary": "이 벌이 담는 항목 묶음." + }, + "key": { + "name_ko": "줄을 가르는 칸", + "summary": "이 표에서 줄 하나를 집는 칸 이름." + }, + "manager_thresholds": { + "name_ko": "안전관리자 선임 기준액", + "summary": "안전관리자를 둬야 하는 금액 문턱." + }, + "master": { + "name_ko": "마스터 자리", + "summary": "공종 코드를 읽어 오는 마스터 파일." + }, + "material_unit_note": { + "name_ko": "자재 밑수 비고", + "summary": "자재 환산을 어느 창이 하는지." + }, + "minimum_estimated_amount_krw": { + "name_ko": "적용 하한 — 추정금액", + "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + }, + "minimum_total_construction_amount_krw": { + "name_ko": "적용 하한 — 총공사금액", + "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + }, + "normalization_status": { + "name_ko": "정돈 상태", + "summary": "자원 코드 정돈이 끝났는지." + }, + "not_found": { + "name_ko": "못 찾은 것", + "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것." + }, + "not_here": { + "name_ko": "이 표가 안 다루는 것", + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + }, + "note": { + "name_ko": "설명", + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + }, + "option_key": { + "name_ko": "고르개 키", + "summary": "화면 고르개가 쓰는 저장 칸 이름." + }, + "option_note": { + "name_ko": "고르개 비고", + "summary": "고르개를 만들 때 챙길 것." + }, + "pending_user": { + "name_ko": "사용자 확정 대기", + "summary": "아직 사람이 답해야 닫히는 물음." + }, + "policy": { + "name_ko": "방침", + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + }, + "rank": { + "name_ko": "우선순위", + "summary": "원문끼리 어긋날 때 어느 쪽을 먼저 보는지." + }, + "rate_from_2033_percent": { + "name_ko": "2033년 이후 요율", + "summary": "표 밖으로 나가는 해부터 쓰는 율(%)." + }, + "reliability_note": { + "name_ko": "신뢰도 기호 뜻풀이", + "summary": "노임 표의 * · ** 가 무엇을 뜻하는지." + }, + "representation": { + "name_ko": "담은 꼴", + "summary": "원문 표를 어떤 꼴로 담았는지." + }, + "reuse_note": { + "name_ko": "사용횟수 비고", + "summary": "닮은 두 사용횟수를 하나로 잇지 않는 까닭." + }, + "scope": { + "name_ko": "걸치는 범위", + "summary": "어디까지 쓰는 값인지." + }, + "source": { + "name_ko": "출처", + "summary": "어느 원문에서 왔는지." + }, + "source_product_code": { + "name_ko": "원천 제품코드", + "summary": "원천이 쓰는 제품 코드." + }, + "source_product_name": { + "name_ko": "원천 제품명", + "summary": "원천이 쓰는 제품 이름." + }, + "stats": { + "name_ko": "집계", + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + }, + "steps_m": { + "name_ko": "직고 칸", + "summary": "표가 갈리는 높이 경계(m)." + }, + "typical_forest_road_applicability": { + "name_ko": "임도에 걸리는지", + "summary": "보통 임도 규모에서 이 항목이 서는지." + }, + "unit": { + "name_ko": "값 단위", + "summary": "이 벌의 값이 무엇 단위인지." + }, + "unit_conversion": { + "name_ko": "단위 환산", + "summary": "단가 단위와 원단위 단위가 달라 맞추는 값." + }, + "why": { + "name_ko": "까닭", + "summary": "이 벌을 왜 따로 두는지." + } + }, + "value_overrides": { + "coef::variables/rate_tool": { + "name_ko": "공구손료율", + "summary": "주요 재료비에 곱하는 공구손료 율(하한·상한). 어느 값을 쓸지는 고름이 필요하다." + }, + "coef::variables/surcharge_labor/unit": { + "name_ko": "값 단위", + "summary": "할증은 %다." + }, + "coef::variables/surcharge_mat/unit": { + "name_ko": "값 단위", + "summary": "할증은 %다." + }, + "formwork_reuse::reuse_ratio_pct": { + "name_ko": "사용횟수별 기준수량 비율", + "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다." + }, + "formwork_reuse::shoring": { + "name_ko": "동바리", + "summary": "강관동바리가 어느 구조물에 서는지." + }, + "fx::variables/fx_cny": { + "name_ko": "환율 — 중국 위안", + "summary": "1 CNY 가 몇 원인지." + }, + "fx::variables/fx_eur": { + "name_ko": "환율 — 유로", + "summary": "1 EUR 가 몇 원인지." + }, + "fx::variables/fx_gbp": { + "name_ko": "환율 — 영국 파운드", + "summary": "1 GBP 가 몇 원인지." + }, + "fx::variables/fx_jpy100": { + "name_ko": "환율 — 일본 엔 100", + "summary": "100 JPY 가 몇 원인지." + }, + "fx::variables/fx_usd": { + "name_ko": "환율 — 미국 달러", + "summary": "1 USD 가 몇 원인지." + }, + "labor_const::variables/aliases": { + "name_ko": "노임 별칭", + "summary": "코드 대신 쓰는 별칭 이름표(labor_1002 = 보통인부)." + }, + "mach_base::parse_audit": { + "name_ko": "읽기 점검", + "summary": "취득가 표에서 못 읽은 줄이 있는지 적어 둔 자리." + }, + "mach_base::variables/mach_fuel_rate/unit": { + "name_ko": "값 단위", + "summary": "연료량은 L/시간이다." + }, + "mach_base::variables/mach_loss_coef/unit": { + "name_ko": "값 단위", + "summary": "손료계수는 1/시간이다." + }, + "mach_base::variables/mach_price/unit": { + "name_ko": "값 단위", + "summary": "취득가는 천원 단위다." + }, + "mach_base::variables/mach_rock_adj/unit": { + "name_ko": "값 단위", + "summary": "할증은 %다." + }, + "mach_price::unit": { + "name_ko": "값 단위", + "summary": "취득가는 천원 단위다." + }, + "machine_operating::dropped_tables": { + "name_ko": "못 읽어 버린 표", + "summary": "칸 수가 안 맞아 못 읽고 버린 원문 표." + }, + "masonry_back_length::table_cm": { + "name_ko": "뒷길이 범위표", + "summary": "메·찰마다 직고 칸별 뒷길이 하한·상한(㎝)." + }, + "masonry_class::bond": { + "name_ko": "쌓기 방식", + "summary": "메쌓기·찰쌓기로 공종코드가 갈린다." + }, + "masonry_class::boulder_diameter": { + "name_ko": "돌 직경 갈래", + "summary": "큰돌쌓기 — 저장 제원 stone_cm 으로 갈린다." + }, + "masonry_class::face_slope": { + "name_ko": "전면 기울기", + "summary": "형식마다 정해진 1:n. 코드 기본 0.3 이 여기서 왔다." + }, + "masonry_wet::code": { + "name_ko": "항목 코드", + "summary": "라이브러리 항목 코드." + }, + "masonry_wet::differs_from_engine": { + "name_ko": "전개와 다른 자리", + "summary": "지금 돌아가는 전개와 이 양식이 갈리는 곳을 적어 둔 자리." + }, + "masonry_wet::item_kind": { + "name_ko": "항목 갈래", + "summary": "양식형(form)인지 고정형인지." + }, + "masonry_wet::library_tier": { + "name_ko": "라이브러리 단", + "summary": "프로그램·회사·개인 가운데 어느 단의 항목인지." + }, + "masonry_wet::name": { + "name_ko": "항목 이름", + "summary": "화면에 보이는 이름." + }, + "masonry_wet::type_id": { + "name_ko": "구조물 종류 키", + "summary": "어느 구조물 종류에 붙는 양식인지." + }, + "masonry_wet::unit_price/note": { + "name_ko": "일위대가 틀 설명", + "summary": "일위대가 줄을 어떻게 세우는지 적은 글." + }, + "mat_price_public::selection_policy": { + "name_ko": "고르는 방침", + "summary": "같은 물품이 여러 번 공고되면 어느 것을 쓰는지." + }, + "mat_price_public::source_row_count": { + "name_ko": "원천 줄 수", + "summary": "걸러 내기 전 원본 줄 수." + }, + "material_surcharge::observed_practice": { + "name_ko": "실무 관측", + "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다." + }, + "oil::variables/oil_diesel": { + "name_ko": "경유 단가", + "summary": "전국평균 경유 값(원/L)." + }, + "oil::variables/oil_gasoline": { + "name_ko": "휘발유 단가", + "summary": "전국평균 휘발유 값(원/L)." + }, + "oil_regional::variables/oil_diesel/unit": { + "name_ko": "값 단위", + "summary": "단가는 원/L 이다." + }, + "oil_regional::variables/oil_gasoline/unit": { + "name_ko": "값 단위", + "summary": "단가는 원/L 이다." + }, + "rates::processing_rules": { + "name_ko": "셈 규칙", + "summary": "요율만으로는 금액이 안 나온다 — 밑수를 어떻게 만들고 어디서 자르는지 적은 자리. 코드와 어긋나면 거울 시험이 잡는다." + }, + "rates::variables/rate_asbestos_contribution": { + "name_ko": "석면피해구제 분담금", + "summary": "노무비에 곱하는 율." + }, + "rates::variables/rate_care": { + "name_ko": "노인장기요양보험료", + "summary": "건강보험료 금액에 곱하는 율." + }, + "rates::variables/rate_health": { + "name_ko": "건강보험료", + "summary": "직접노무비에 곱하는 율." + }, + "rates::variables/rate_retirement_mutual_aid": { + "name_ko": "퇴직공제부금비", + "summary": "직접노무비에 곱하는 율. 추정금액 하한이 있다." + }, + "rates::variables/rate_safety_base": { + "name_ko": "산업안전보건관리비 기초액", + "summary": "요율표 안에 함께 든 기초액이 걸리는 구간." + }, + "rates::variables/rate_sanjae": { + "name_ko": "산재보험료", + "summary": "노무비에 곱하는 율." + }, + "rates::variables/rate_vat": { + "name_ko": "부가가치세", + "summary": "공급가액에 곱하는 율." + }, + "rates::variables/rate_wage_claim_contribution": { + "name_ko": "임금채권보장 부담금", + "summary": "노무비에 곱하는 율." + }, + "rebar_complexity::price_hint_krw_per_ton": { + "name_ko": "단가 참고값", + "summary": "표시 전용 — 갈래 차이를 사람이 보라고 둔 값. 계산에 안 들어간다." + }, + "stone_kind::back_lengths_cm": { + "name_ko": "뒷길이 규격", + "summary": "표가 값을 주는 뒷길이 일곱 칸." + }, + "stone_kind::backfill_ratio_of_back_length": { + "name_ko": "뒤채움 몫", + "summary": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이다." + }, + "stone_kind::fill_concrete_m3_per_m2": { + "name_ko": "채움 콘크리트 원단위", + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. 원문이 두 줄뿐이다." + }, + "stone_kind::kinds": { + "name_ko": "돌 종류", + "summary": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌 네 줄." + }, + "stone_kind::no_folding": { + "name_ko": "접지 않음 규칙", + "summary": "표에 없는 뒷길이를 가까운 칸으로 접지 않는다 — 접으면 값이 조용히 틀린다." + }, + "stone_kind::wedge_stone_m3_per_m2": { + "name_ko": "고임돌 원단위", + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. null 은 원문 「-」다." + }, + "work_item_mapping::concrete_placing": { + "name_ko": "콘크리트 타설 잇기", + "summary": "타설 방식 × 구조물 종류로 공종이 갈리는 자리." + }, + "work_item_mapping::ground_aliases_moved_to": { + "name_ko": "갈래 별칭 옮긴 자리", + "summary": "갈래 이름 별칭은 별칭표로 옮겼다. 여기 다시 두지 않는다." + }, + "work_item_mapping::masonry_class_reference": { + "name_ko": "돌쌓기 갈래 참고 자리", + "summary": "돌쌓기 갈래는 이제 참고용이고 어긋나면 그것이 신호다." + }, + "work_item_mapping::pipe": { + "name_ko": "배수관 잇기", + "summary": "관종으로 공종이 갈린다. 관 정본은 pipe_points 다." + }, + "work_item_mapping::variant_contract": { + "name_ko": "갈래 키 계약", + "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속." + } + }, + "unknown": [] +} diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py new file mode 100644 index 00000000..e10346b7 --- /dev/null +++ b/resources/tester/test_master_labels_cover.py @@ -0,0 +1,273 @@ +"""마스터 이름표(`resources/data_master_labels/labels_2026-01-01.json`) 덮임 시험. + +이름표는 **값을 안 담고 이름만 담는다** — 그래서 마스터가 늘거나 열이 바뀌면 +이름표가 조용히 뒤처진다. 이 시험이 그 어긋남을 잡는다. + +- 마스터에 있는데 이름표에 없는 표·열 → 빨강 +- 이름표에 있는데 마스터에 없는 표·열(묵은 이름표) → 빨강 +- 갈래 나눔은 2026-09-15 브레인 갈래표(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1)와 대조 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" + +#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다. +BRAIN_KIND_COUNTS = {"logic": 17, "base_value": 9, "byproduct": 4, "seed": 1} +MANIFEST_PREFIX = "manifest_" + +META_KEYS = { + "schema_version", + "dataset_id", + "effective_date", + "generated_at", + "publication_date", + "survey_month", + "pum_edition", + "dataset_version", + "source_master_file", + "source_dataset_version", +} + + +@pytest.fixture(scope="module") +def labels() -> dict: + return json.loads(LABELS_PATH.read_text(encoding="utf-8")) + + +def _is_table(node) -> str | None: + """이름표 생성기와 **같은 잣대** — 화면이 표로 그릴 마디인가.""" + if isinstance(node, list) and node and all(isinstance(r, dict) for r in node): + return "list" + if isinstance(node, dict): + values = list(node.values()) + dicts = [v for v in values if isinstance(v, dict)] + if ( + len(values) >= 2 + and len(dicts) == len(values) + and not any( + isinstance(v.get("records"), list) or isinstance(v.get("rows"), list) for v in dicts + ) + ): + keysets = [frozenset(v.keys()) for v in dicts] + if len(set(keysets)) <= max(2, len(keysets) // 3 + 1): + return "map" + return None + + +def _has_table(node, depth: int = 0) -> bool: + if _is_table(node): + return True + if depth > 5 or not isinstance(node, dict): + return False + return any(_has_table(v, depth + 1) for v in node.values()) + + +def _collect(node, path, tables, values, depth: int = 0) -> None: + shape = _is_table(node) + if shape: + tables.append(("/".join(path), shape, node)) + return + if depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()): + for key, value in node.items(): + _collect(value, path + [key], tables, values, depth + 1) + return + values.append("/".join(path)) + + +def _scan(path: Path) -> tuple[list, list]: + doc = json.loads(path.read_text(encoding="utf-8")) + tables, values = [], [] + for key, value in doc.items(): + if key in META_KEYS: + continue + if key == "variables" and isinstance(value, dict) and not _is_table(value): + for sub_key, sub in value.items(): + _collect(sub, ["variables", sub_key], tables, values) + else: + _collect(value, [key], tables, values) + return tables, values + + +def _columns_of(node, shape: str) -> list[str]: + records = node if shape == "list" else list(node.values()) + seen: list[str] = [] + for record in records: + if not isinstance(record, dict): + continue + for key in record: + if key not in seen: + seen.append(key) + return seen + + +def test_이름표_파일이_읽힌다(labels): + assert labels["dataset_id"] == "data_master_labels" + assert labels["files"], "이름표에 파일이 하나도 없다" + + +def test_이름표가_가리키는_파일이_다_있다(labels): + missing = [f["path"] for f in labels["files"] if not (ROOT / f["path"]).is_file()] + assert not missing, f"이름표가 없는 파일을 가리킨다: {missing}" + + +def test_갈래_나눔이_브레인_표와_같다(labels): + counted: dict[str, int] = {} + for entry in labels["files"]: + if entry["file_id"].startswith(MANIFEST_PREFIX): + continue + counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1 + assert counted == BRAIN_KIND_COUNTS + assert sum(counted.values()) == 31 + + +def test_갈래_이름이_다_풀려_있다(labels): + known = set(labels["kinds"]) + used = {f["kind"] for f in labels["files"]} + assert used <= known, f"뜻을 안 적은 갈래: {sorted(used - known)}" + + +def test_마스터의_표가_이름표에_다_있다(labels): + missing = [] + for entry in labels["files"]: + tables, _ = _scan(ROOT / entry["path"]) + labelled = {t["key"] for t in entry["tables"]} + for key, _shape, _node in tables: + if key not in labelled: + missing.append(f"{entry['file_id']}::{key}") + assert not missing, f"이름표에 없는 표: {missing}" + + +def test_이름표의_표가_마스터에_다_있다(labels): + stale = [] + for entry in labels["files"]: + tables, _ = _scan(ROOT / entry["path"]) + actual = {key for key, _s, _n in tables} + for table in entry["tables"]: + if table["key"] not in actual: + stale.append(f"{entry['file_id']}::{table['key']}") + assert not stale, f"마스터에 없는 묵은 이름표: {stale}" + + +def test_마스터의_값_묶음이_이름표에_다_있다(labels): + missing = [] + for entry in labels["files"]: + _tables, values = _scan(ROOT / entry["path"]) + labelled = {v["key"] for v in entry["value_groups"]} + for key in values: + if key not in labelled: + missing.append(f"{entry['file_id']}::{key}") + assert not missing, f"이름표에 없는 값 묶음: {missing}" + + +def test_표_이름이_비지_않았다(labels): + blank = [ + f"{entry['file_id']}::{table['key']}" + for entry in labels["files"] + for table in entry["tables"] + if not table["name_ko"].strip() + ] + assert not blank, f"한글 이름이 빈 표: {blank}" + + +def test_값_묶음_이름이_비지_않았다(labels): + blank = [ + f"{entry['file_id']}::{group['key']}" + for entry in labels["files"] + for group in entry["value_groups"] + if not group["name_ko"].strip() + ] + assert not blank, f"한글 이름이 빈 값 묶음: {blank}" + + +def test_마스터의_열이_이름표에_다_있다(labels): + missing = [] + for entry in labels["files"]: + tables, _ = _scan(ROOT / entry["path"]) + by_key = {t["key"]: t for t in entry["tables"]} + for key, shape, node in tables: + table = by_key.get(key) + if table is None: + continue + labelled = {c["key"] for c in table["columns"]} + for column in _columns_of(node, shape): + if column not in labelled: + missing.append(f"{entry['file_id']}::{key}/{column}") + assert not missing, f"이름표에 없는 열: {missing}" + + +def test_이름표의_열이_마스터에_다_있다(labels): + stale = [] + for entry in labels["files"]: + tables, _ = _scan(ROOT / entry["path"]) + actual = {key: _columns_of(node, shape) for key, shape, node in tables} + for table in entry["tables"]: + known = set(actual.get(table["key"], ())) + for column in table["columns"]: + if column["key"] not in known: + stale.append(f"{entry['file_id']}::{table['key']}/{column['key']}") + assert not stale, f"마스터에 없는 묵은 열 이름표: {stale}" + + +def test_열_이름은_붙었거나_사유가_있다(labels): + bad = [] + for entry in labels["files"]: + for table in entry["tables"]: + for column in table["columns"]: + if column["name_ko"].strip(): + continue + if not column.get("unknown_reason", "").strip(): + bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}") + assert not bad, f"이름도 사유도 없는 열: {bad}" + + +def test_이름_없는_열은_unknown_에도_적혀_있다(labels): + listed = {u["where"] for u in labels["unknown"]} + for entry in labels["files"]: + for table in entry["tables"]: + for column in table["columns"]: + if column["name_ko"].strip(): + continue + where = f"{entry['file_id']}::{table['key']}/{column['key']}" + assert where in listed, f"unknown 에 안 적힌 빈 열: {where}" + + +def test_찾는_차례가_적혀_있다(labels): + order = labels["lookup_order"]["column"] + assert order[0].startswith("column_overrides") + assert order[1].startswith("columns") + + +def test_덮어쓰기_이름표가_다_쓰인다(labels): + """`column_overrides` 는 「파일id/열key」 꼴이고 그 파일에 실제로 그 열이 있어야 한다.""" + by_file = {} + for entry in labels["files"]: + cols = set() + for table in entry["tables"]: + cols |= {c["key"] for c in table["columns"]} + by_file[entry["file_id"]] = cols + dangling = [] + for key in labels["column_overrides"]: + file_id, _, column = key.partition("/") + if column not in by_file.get(file_id, set()): + dangling.append(key) + assert not dangling, f"쓰이지 않는 열 덮어쓰기: {dangling}" + + +def test_세어_둔_수가_실제와_같다(labels): + counts = labels["counts"] + assert counts["files"] == len(labels["files"]) + assert counts["tables"] == sum(len(f["tables"]) for f in labels["files"]) + assert counts["columns"] == sum(len(t["columns"]) for f in labels["files"] for t in f["tables"]) + assert counts["value_groups"] == sum(len(f["value_groups"]) for f in labels["files"]) + assert counts["unknown"] == len(labels["unknown"]) + + +def test_강우_IDF_캐시는_안_담았다(labels): + assert not [f for f in labels["files"] if "rainfall" in f["path"]] From 7797a2ffc1bab097321db358d8cffbb714e1dadf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:01:52 +0900 Subject: [PATCH 03/48] =?UTF-8?q?fix(z01):=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=A7=80=EB=AC=B8=20`=5Fmanifest`=20=EC=85=8B=EC=9D=84=20?= =?UTF-8?q?=EB=B6=80=EC=82=B0=EB=AC=BC=EC=97=90=EC=84=9C=20=E3=80=8C?= =?UTF-8?q?=EC=A7=80=EB=AC=B8=E3=80=8D=20=EA=B0=88=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부산물 설명이 「정본 아님 · 계산이 남긴 기록」인데 `_manifest` 는 **어느 판으로 셈했는지 못박는 표**라 딱지가 거꾸로 붙어 있었음(브레인 ②) - 갈래 하나 더 둠 — `fingerprint` 「지문」: 값도 규칙도 아니고 판을 가리키는 자리 · 고치지 않음 - 시험 한 건 더 — 지문이 부산물로 돌아가면 빨강 · 「정본」 딱지가 다시 붙어도 빨강 - 표·열·값 묶음 수는 그대로(표 91 · 열 447 · 값 묶음 172 · 뜻 못 밝힌 열 0) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 26 +++++++++++-------- resources/tester/test_master_labels_cover.py | 10 +++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 6875d5b8..4e79b5b9 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T18:55:59+09:00", + "generated_at": "2026-09-15T18:59:13+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -10,7 +10,7 @@ "unit_is_label_only": "unit 은 이름표에서만 정한다. 자료에 없는 단위를 만들어 붙이지 않는다.", "counting_rule": "표 = 화면이 표로 그릴 마디(줄이 여럿인 마디). 값 묶음 = 표가 아닌 마디(설명·방침·한 값짜리 요율 따위).", "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", - "out_of_31": "폴더 장부(_manifest.json) 셋은 브레인 31 밖이라 부산물로 둔다.", + "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다." }, "lookup_order": { @@ -38,6 +38,10 @@ "name_ko": "부산물", "summary": "다른 벌에서 나온 기록. ⚠ 정본이 아니다 — 고쳐도 정본이 안 바뀐다." }, + "fingerprint": { + "name_ko": "지문", + "summary": "어느 판으로 셈했는지 못박는 자리. 값도 규칙도 아니고 **판을 가리키는 표**다 — 고치지 않는다." + }, "seed": { "name_ko": "씨앗", "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." @@ -4552,9 +4556,9 @@ { "file_id": "manifest_cost_input_value", "path": "resources/data_cost_input_value/_manifest.json", - "name_ko": "원가 입력변수 — 파일 장부", - "summary": "실린 파일·적용일·지문 목록. 브레인 갈래표 31 밖의 장부다.", - "kind": "byproduct", + "name_ko": "원가 입력변수 — 판 지문", + "summary": "이 폴더를 어느 판으로 셈했는지 못박는 지문. 값이 아니라 판을 가리키는 자리다.", + "kind": "fingerprint", "tables": [ { "key": "deferred", @@ -4694,9 +4698,9 @@ { "file_id": "manifest_material_surcharge", "path": "resources/data_material_surcharge/_manifest.json", - "name_ko": "재료 할증률 — 파일 장부", - "summary": "실린 파일·지문 목록. 브레인 갈래표 31 밖의 장부다.", - "kind": "byproduct", + "name_ko": "재료 할증률 — 판 지문", + "summary": "이 폴더를 어느 판으로 셈했는지 못박는 지문. 값이 아니라 판을 가리키는 자리다.", + "kind": "fingerprint", "tables": [ { "key": "files", @@ -4737,9 +4741,9 @@ { "file_id": "manifest_work_item_master", "path": "resources/data_work_item_master/_manifest.json", - "name_ko": "공종 마스터 — 파일 장부", - "summary": "실린 파일·지문 목록. 브레인 갈래표 31 밖의 장부다.", - "kind": "byproduct", + "name_ko": "공종 마스터 — 판 지문", + "summary": "이 폴더를 어느 판으로 셈했는지 못박는 지문. 값이 아니라 판을 가리키는 자리다.", + "kind": "fingerprint", "tables": [ { "key": "files", diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index e10346b7..3e9ec49a 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -133,6 +133,16 @@ def test_갈래_이름이_다_풀려_있다(labels): assert used <= known, f"뜻을 안 적은 갈래: {sorted(used - known)}" +def test_폴더_지문은_부산물이_아니다(labels): + """`_manifest` 는 계산이 남긴 기록이 아니라 **어느 판으로 셈했는지 못박는 표**다 — + 「정본 아님」 딱지가 붙으면 안 된다(2026-09-15 브레인 ②).""" + manifests = [f for f in labels["files"] if f["file_id"].startswith(MANIFEST_PREFIX)] + assert manifests, "폴더 지문이 이름표에 하나도 없다" + for entry in manifests: + assert entry["kind"] == "fingerprint", f"{entry['file_id']} 갈래가 {entry['kind']} 다" + assert "정본" not in labels["kinds"]["fingerprint"]["summary"] + + def test_마스터의_표가_이름표에_다_있다(labels): missing = [] for entry in labels["files"]: From 827054f8b1155ee330899876cdfd46f39ce00154 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:03:27 +0900 Subject: [PATCH 04/48] =?UTF-8?q?feat(z01):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9D=BD=EA=B8=B0=20API=20?= =?UTF-8?q?=EC=B2=AB=20=EB=B2=8C=20=E2=80=94=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EB=84=B7(=EB=A1=9C=EC=A7=81=2017=C2=B7=EA=B8=B0=EC=B4=88?= =?UTF-8?q?=EA=B0=92=209=C2=B7=EB=B6=80=EC=82=B0=EB=AC=BC=204=C2=B7?= =?UTF-8?q?=EC=94=A8=EC=95=97=201)=20=E2=86=92=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=E2=86=92=20=ED=91=9C=20=ED=8A=B8=EB=A6=AC=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=20=EC=A4=84=20=EC=AA=BD=20=EB=82=98=EB=88=84=EA=B8=B0=C2=B7?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=C2=B7=20=EC=9D=B4=EB=A6=84=ED=91=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20label=C2=B7unit=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20id=C2=B7sha=C2=B7=EC=83=9D=EC=84=B1=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EC=88=A8=EA=B9=80=20=C2=B7=20=EA=B0=95=EC=9A=B0=20?= =?UTF-8?q?IDF=20=EC=BA=90=EC=8B=9C=20=EB=BA=8C(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20=EC=83=88=20=ED=8C=90=20=C2=B7=20=ED=91=9C=20id=20?= =?UTF-8?q?=EB=8A=94=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_Router.py | 29 +++ Z01_MasterData/Z01_MasterData_Tables.py | 280 +++++++++++++++++++++++ resources/tester/test_z01_master_data.py | 173 ++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 Z01_MasterData/Z01_MasterData_Router.py create mode 100644 Z01_MasterData/Z01_MasterData_Tables.py create mode 100644 resources/tester/test_z01_master_data.py diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py new file mode 100644 index 00000000..e3e1e315 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -0,0 +1,29 @@ +"""Z01 마스터 데이터 라우터 — 읽기 전용(고치기는 다음 차례 · 2026-09-15 브레인). + + GET /api/master-data/tree 갈래 → 파일 → 표 + GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) +⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from Z01_MasterData import Z01_MasterData_Tables as tables + +router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) + + +@router.get("/tree") +def get_tree() -> dict: + return tables.tree() + + +@router.get("/rows") +def get_rows( + file: str, table: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" +) -> dict: + result = tables.rows(file, table, page=page, size=size, q=q) + if result is None: + raise HTTPException(status_code=404, detail="없는 파일이나 표입니다.") + return result diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py new file mode 100644 index 00000000..0779b85f --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -0,0 +1,280 @@ +"""Z01 마스터 데이터 — `resources/` 마스터 JSON 을 **갈래 → 파일 → 표 → 줄** 로 가름(읽기 전용). + +마스터 = 로직 + 기초값(2026-09-15 사용자 방향 · bottom-up). 가르는 잣대 = 「값이 바뀌는 것 = 기초값 · 규칙·표 = 로직」(브레인). +표 가르기(브레인 승인): + · `variables` 칸은 변수 하나 = 표 하나 — 변수 안에 목록이 둘 이상이면 한 표에 잇고 `@part` 열에 목록 이름 + · 나머지는 줄 모음마다 표 하나 — 딕셔너리 목록 · 같은 길이 **수** 열(글 목록은 길이가 같아도 짝짓지 않음) · + 딕셔너리의 딕셔너리(`@key` 열) · 값 딕셔너리(`@key`·`@value` 줄) + · 설명(note·source·policy …)은 표가 아님 — 줄 안의 설명 칸은 그대로 둠 +한글 이름·단위는 **이름표 파일**(`resources/data_master_labels/*.json` · 데스크탑 서브 몫)에서만 — 없으면 영문 key. +""" + +from __future__ import annotations + +import json +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +RESOURCES = Path(__file__).resolve().parent.parent / "resources" +LABEL_DIR = RESOURCES / "data_master_labels" +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 500 + +# 갈래 나눔표(브레인 2026-09-15) — 차례가 화면 차례. 여기 없는 파일(강우 IDF 캐시 등)은 안 보임. +GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("logic", ( + "pum_forest", "pum_const", # 품셈 원문 + "work_item_master", # 공종 축 + "coef", "material_surcharge", "formwork_reuse", "rebar_complexity", "timber_structure_class", + "masonry_class", "masonry_slope", "masonry_back_length", "stone_kind", "revetment_sabang", + "structure_unit_observed", # 계수 + "work_item_mapping", "aliases", "resource_catalog_ext", # 잇는 표 + )), + ("base", ( + "labor_const", "labor_mfg", "mach_base", "machine_operating", "mat_price_public", + "oil", "oil_regional", "fx", "rates", + )), + ("byproduct", ("basis_missing", "form_undetermined", "resource_axis", "unmatched")), # 정본 아님 — 기록 + ("seed", ("masonry_wet",)), +) # fmt: skip + +# 파일 머리 — 표가 아님 +_HEAD_KEYS = { + "source", "sources", "policy", "derived_from", "source_dataset_version", "source_master_file", + "dataset_version", "stats", "master", "parse_audit", "variables", +} # fmt: skip +_NOTE_KEYS = {"note", "notes", "source", "sources", "quote", "why", "policy"} +_HIDDEN_KEYS = {"sha256", "generated_at", "raw_row_index", "sort_order"} +_DATE_SUFFIX = re.compile(r"_\d{4}(?:-\d{2}-\d{2})?$") + + +def _is_note(key: str) -> bool: + return key in _NOTE_KEYS or key.endswith("_note") + + +def _scalar(v: Any) -> bool: + return not isinstance(v, (dict, list)) + + +def _flat(d: dict, prefix: str = "") -> dict[str, Any]: + """줄 하나 — 안쪽 딕셔너리는 `a.b` 열로 펼치고 목록은 값 그대로.""" + out: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict) and v: + out.update(_flat(v, f"{prefix}{k}.")) + else: + out[f"{prefix}{k}"] = v + return out + + +def _list_rows(items: list) -> list[dict[str, Any]]: + return [_flat(x) if isinstance(x, dict) else {"@value": x} for x in items] + + +def _numeric(v: Any) -> bool: + if isinstance(v, list): + return all(_numeric(x) and not isinstance(x, list) for x in v) + return v is None or (isinstance(v, (int, float)) and not isinstance(v, bool)) + + +def _columnar(d: dict) -> dict[str, list] | None: + """같은 길이 수 열(2개 이상) — 안쪽 한 겹 딕셔너리(`columns`)는 끌어올림.""" + cols: dict[str, list] = {} + for k, v in d.items(): + if _is_note(k): + continue + if isinstance(v, dict): + for ck, cv in v.items(): + if _is_note(ck): + continue + if ck in cols or not isinstance(cv, list): + return None + cols[ck] = cv + elif isinstance(v, list) and k not in cols: + cols[k] = v + else: + return None + lengths = {len(v) for v in cols.values()} + if len(cols) < 2 or len(lengths) != 1 or 0 in lengths: + return None + if not all(_numeric(x) for v in cols.values() for x in v): + return None + return cols + + +def _tableish(d: dict) -> bool: + """제 표로 떼어 낼 딕셔너리 — 같은 길이 수 열이거나 그런 것을 품음. 나머지는 한 줄로 펼침.""" + return _columnar(d) is not None or any( + isinstance(v, dict) and _tableish(v) for k, v in d.items() if not _is_note(k) + ) + + +def _dict_rows(d: dict) -> list[dict[str, Any]]: + """목록 없는 딕셔너리 — 안쪽 딕셔너리는 키마다 한 줄(`@key` + 펼친 칸), 값은 키·값 줄.""" + body = {k: v for k, v in d.items() if not _is_note(k)} + kv = [{"@key": k, "@value": v} for k, v in body.items() if not isinstance(v, dict)] + return kv + [{"@key": k, **_flat(v)} for k, v in body.items() if isinstance(v, dict)] + + +def _variable_rows(v: Any) -> list[dict[str, Any]]: + if isinstance(v, list): + return _list_rows(v) + lists = [(k, x) for k, x in v.items() if isinstance(x, list) and not _is_note(k)] + if not lists: + cols = _columnar(v) + return [dict(zip(cols, r)) for r in zip(*cols.values())] if cols else _dict_rows(v) + consts = _flat( + {k: x for k, x in v.items() if not _is_note(k) and (_scalar(x) or isinstance(x, dict))} + ) + # `key` = 어느 열이 키인지 적은 머리 — 줄 값이 아님 + consts = {k: x for k, x in consts.items() if k != "key" and not _is_note(k.rsplit(".", 1)[-1])} + return [ + {**consts, **({"@part": k} if len(lists) > 1 else {}), **row} + for k, items in lists + for row in _list_rows(items) + ] + + +def _collect(node: Any, path: str, out: list[tuple[str, list[dict[str, Any]]]]) -> None: + if isinstance(node, list): + out.append((path, _list_rows(node))) + return + cols = _columnar(node) + if cols: + out.append((path, [dict(zip(cols, r)) for r in zip(*cols.values())])) + return + body = {k: v for k, v in node.items() if not _is_note(k)} + lists = {k: v for k, v in body.items() if isinstance(v, list)} + if lists: # 목록마다 표 하나 · 곁의 값은 줄마다 붙임 + consts = {k: v for k, v in body.items() if _scalar(v)} + for k, v in lists.items(): + out.append((f"{path}/{k}", [{**consts, **r} for r in _list_rows(v)])) + for k, v in body.items(): + if isinstance(v, dict): + _collect(v, f"{path}/{k}", out) + return + split = [k for k, v in body.items() if isinstance(v, dict) and _tableish(v)] + rest = {k: v for k, v in body.items() if k not in split} + if rest: + out.append((path, _dict_rows(rest))) + for k in split: + _collect(body[k], f"{path}/{k}", out) + + +def file_id(path: Path) -> str: + return _DATE_SUFFIX.sub("", path.stem) + + +def master_files() -> dict[str, Path]: + """갈래표에 든 id → 파일(같은 id 가 여러 판이면 이름 차례로 끝 판).""" + wanted = {i for _, ids in GROUPS for i in ids} + found: dict[str, Path] = {} + for p in sorted( + [*RESOURCES.glob("data_*/*.json"), *RESOURCES.glob("library_structure/*.json")] + ): + if file_id(p) in wanted and p.parent != LABEL_DIR: + found[file_id(p)] = p + return found + + +@lru_cache(maxsize=64) +def _tables_of(path: str, _mtime_ns: int) -> tuple[tuple[str, tuple[dict[str, Any], ...]], ...]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + out: list[tuple[str, list[dict[str, Any]]]] = [] + for name, v in (data.get("variables") or {}).items(): + out.append((f"variables/{name}", _variable_rows(v))) + for k, v in data.items(): + if k not in _HEAD_KEYS and not _is_note(k) and not _scalar(v): + _collect(v, k, out) + return tuple((tid, tuple(rows)) for tid, rows in out) + + +def tables_of(path: Path) -> dict[str, tuple[dict[str, Any], ...]]: + return dict(_tables_of(str(path), path.stat().st_mtime_ns)) + + +def load_labels() -> dict[str, dict[str, Any]]: + """이름표 파일 모두를 한 벌로 — 부를 때마다 읽음(작은 파일 · 고치면 바로 보임).""" + merged: dict[str, dict[str, Any]] = {"groups": {}, "files": {}, "tables": {}, "columns": {}} + for p in sorted(LABEL_DIR.glob("*.json")) if LABEL_DIR.is_dir() else []: + data = json.loads(p.read_text(encoding="utf-8")) + for section, entries in merged.items(): + entries.update(data.get(section) or {}) + return merged + + +def _text(entry: Any, key: str = "label") -> str: + if isinstance(entry, dict): + return str(entry.get(key) or "") + return str(entry or "") if key == "label" else "" + + +def tree() -> dict[str, Any]: + labels = load_labels() + files = master_files() + groups = [] + for gkey, ids in GROUPS: + entries = [] + for fid in ids: + if fid not in files: + continue + entries.append( + { + "id": fid, + "label": _text(labels["files"].get(fid)) or fid, + "key": files[fid].name, + "tables": [ + { + "id": tid, + "label": _text(labels["tables"].get(f"{fid}/{tid}")) + or tid.rsplit("/", 1)[-1], + "key": tid.rsplit("/", 1)[-1], + "row_count": len(rows), + } + for tid, rows in tables_of(files[fid]).items() + ], + } + ) + groups.append( + {"key": gkey, "label": _text(labels["groups"].get(gkey)) or gkey, "files": entries} + ) + return {"groups": groups} + + +def rows( + fid: str, tid: str, page: int = 1, size: int = DEFAULT_PAGE_SIZE, q: str = "" +) -> dict[str, Any] | None: + """표 줄 한 쪽 — 없는 파일·표는 None. 검색은 줄 값 글자에 든 것(대소문자 무시).""" + path = master_files().get(fid) + table = tables_of(path).get(tid) if path else None + if table is None: + return None + columns: dict[str, None] = {} + for r in table: + columns.update(dict.fromkeys(r)) + needle = q.strip().lower() + hits = ( + [r for r in table if needle in json.dumps(r, ensure_ascii=False).lower()] + if needle + else list(table) + ) + size = max(1, min(size, MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + col_labels = load_labels()["columns"] + out_cols = [] + for key in columns: + entry = col_labels.get(f"{fid}/{key}") or col_labels.get(key) + hidden = entry.get("hidden") if isinstance(entry, dict) and "hidden" in entry else None + out_cols.append( + { + "key": key, + "label": _text(entry) or key, + "unit": _text(entry, "unit"), + "hidden": bool(hidden) + if hidden is not None + else key.rsplit(".", 1)[-1] in _HIDDEN_KEYS, + } + ) + return {"columns": out_cols, "rows": hits[start : start + size], "total": len(hits)} diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py new file mode 100644 index 00000000..3fbd0891 --- /dev/null +++ b/resources/tester/test_z01_master_data.py @@ -0,0 +1,173 @@ +"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판. + +약속(브레인 승인): 갈래 넷 로직 17 · 기초값 9 · 부산물 4 · 씨앗 1 = 31 파일 · 강우 IDF 캐시는 뺌 · +variables 칸은 변수 하나 = 표 하나 · 나머지는 줄 모음(목록·같은 길이 수 열)마다 표 하나 · 설명(note·source·policy)은 표 아님 · +한글 이름은 이름표 파일(`resources/data_master_labels/`)에서만 — 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · +unit 도 이름표에서만(자료에서 지어내지 않음) · 숨김 = 내부 id · sha · 생성시각. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from Z01_MasterData import Z01_MasterData_Router as router_module +from Z01_MasterData import Z01_MasterData_Tables as tables + +GROUPS = { + "logic": { + "pum_forest", "pum_const", "work_item_master", "coef", "material_surcharge", "formwork_reuse", + "rebar_complexity", "timber_structure_class", "masonry_class", "masonry_slope", "masonry_back_length", + "stone_kind", "revetment_sabang", "structure_unit_observed", "work_item_mapping", "aliases", + "resource_catalog_ext", + }, + "base": { + "labor_const", "labor_mfg", "mach_base", "machine_operating", "mat_price_public", "oil", "oil_regional", + "fx", "rates", + }, + "byproduct": {"basis_missing", "form_undetermined", "resource_axis", "unmatched"}, + "seed": {"masonry_wet"}, +} # fmt: skip + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(tables, "LABEL_DIR", tmp_path / "labels") # 이름표 없음 — 영문 key 그대로 + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _tree(client: TestClient) -> dict: + res = client.get("/api/master-data/tree") + assert res.status_code == 200, res.text + return res.json() + + +def _rows(client: TestClient, **params) -> dict: + res = client.get("/api/master-data/rows", params=params) + assert res.status_code == 200, res.text + return res.json() + + +def test_갈래_넷에_파일_31_강우_캐시는_없음(client: TestClient) -> None: + groups = _tree(client)["groups"] + assert [g["key"] for g in groups] == ["logic", "base", "byproduct", "seed"] + for g in groups: + assert {f["id"] for f in g["files"]} == GROUPS[g["key"]], g["key"] + assert g["label"] == g["key"] # 이름표가 없으면 영문 key + for f in g["files"]: + assert f["label"] == f["id"] and f["key"].endswith(".json") and f["tables"], f["id"] + ids = [f["id"] for g in groups for f in g["files"]] + assert len(ids) == 31 and not any("idf" in i or "rainfall" in i for i in ids) + + +def test_variables_는_변수_하나가_표_하나(client: TestClient) -> None: + files = {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]} + rates = [t for t in files["rates"]["tables"] if t["id"].startswith("variables/")] + assert len(rates) == 19, [t["id"] for t in rates] + vat = next(t for t in rates if t["id"] == "variables/rate_vat") + assert ( + vat["key"] == "rate_vat" and vat["label"] == "rate_vat" and vat["row_count"] == 2 + ) # rate_percent · base + assert len([t for t in files["coef"]["tables"] if t["id"].startswith("variables/")]) == 5 + assert "processing_rules" in {t["id"] for t in files["rates"]["tables"]} # 변수 밖 규칙도 표 + # 한 변수에 목록이 둘이면 한 표에 잇고 어느 목록인지 열로 가름 + fuel = _rows(client, file="mach_base", table="variables/mach_fuel_rate", size=500) + assert fuel["total"] == 214 + 21 + assert {r["@part"] for r in fuel["rows"]} >= {"parsed_records"} + + +def test_자재_단가는_쪽으로_나눠_주고_검색으로_좁힘(client: TestClient) -> None: + first = _rows(client, file="mat_price_public", table="variables/mat_price", page=1, size=50) + second = _rows(client, file="mat_price_public", table="variables/mat_price", page=2, size=50) + assert first["total"] == 6999 and len(first["rows"]) == 50 and first["rows"] != second["rows"] + assert {c["key"] for c in first["columns"]} >= {"item_code", "price_krw", "specification"} + assert "key" not in { + c["key"] for c in first["columns"] + } # 「어느 열이 키인지」 머리는 줄 값이 아님 + found = _rows( + client, file="mat_price_public", table="variables/mat_price", q="육각볼트", size=500 + ) + assert 0 < found["total"] < 6999 + assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in found["rows"]) + capped = _rows(client, file="mat_price_public", table="variables/mat_price", size=100000) + assert len(capped["rows"]) == tables.MAX_PAGE_SIZE + + +def test_같은_길이_수_열은_줄로_세움(client: TestClient) -> None: + slope = _rows(client, file="masonry_slope", table="table/메쌓기") + assert slope["total"] == 5 and slope["rows"][0] == {"성토": 0.3, "절토": 0.25} + wedge = _rows(client, file="masonry_wet", table="tables/고임돌표") + assert wedge["total"] == 7 and wedge["rows"][0]["keys"] == 25 + # 글 목록 둘은 길이가 같아도 짝짓지 않음(자재 이름 ↔ 뒤진 자리) + files = {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]} + ids = {t["id"] for t in files["material_surcharge"]["tables"]} + assert {"not_found/materials", "not_found/checked"} <= ids + + +def test_원문_표는_번호로_찾음(client: TestClient) -> None: + hit = _rows(client, file="pum_forest", table="variables/pum", q="F0155") + assert [r["table_id"] for r in hit["rows"]] == ["F0155"] + + +def test_내부_id_sha_생성시각은_숨김(client: TestClient) -> None: + axis = _rows(client, file="resource_axis", table="rows", size=1) + hidden = {c["key"]: c["hidden"] for c in axis["columns"]} + assert hidden["raw_row_index"] is True and hidden["resource_name"] is False + master = _rows(client, file="work_item_master", table="work_items", size=1) + assert {c["key"]: c["hidden"] for c in master["columns"]}["sort_order"] is True + assert all(c["unit"] == "" and c["label"] == c["key"] for c in axis["columns"]) # 이름표 없음 + + +def test_이름표_파일이_있으면_label_unit_을_붙임(client: TestClient, tmp_path: Path) -> None: + labels = tmp_path / "labels" + labels.mkdir() + (labels / "labels.json").write_text( + json.dumps( + { + "groups": {"base": "기초값"}, + "files": {"rates": "요율"}, + "tables": {"rates/variables/rate_vat": "부가가치세"}, + "columns": { + "base": {"label": "밑수"}, + "rates/rate_percent": {"label": "요율", "unit": "%"}, + "rate_percent": {"label": "딴 이름", "unit": "딴 단위"}, + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + base = next(g for g in _tree(client)["groups"] if g["key"] == "base") + rates = next(f for f in base["files"] if f["id"] == "rates") + assert base["label"] == "기초값" and rates["label"] == "요율" + assert ( + next(t for t in rates["tables"] if t["id"] == "variables/rate_vat")["label"] == "부가가치세" + ) + cols = {c["key"]: c for c in _rows(client, file="rates", table="variables/rate_vat")["columns"]} + assert cols["@key"]["label"] == "@key" # 없는 것은 영문 key + vat = _rows(client, file="rates", table="variables/rate_sanjae") + assert {r["@key"] for r in vat["rows"]} == {"rate_percent", "base"} + goyong = { + c["key"]: c for c in _rows(client, file="rates", table="variables/rate_goyong")["columns"] + } + assert ( + goyong["rate_percent"]["label"] == "요율" and goyong["rate_percent"]["unit"] == "%" + ) # 파일 것이 이김 + assert goyong["base"]["label"] == "밑수" and goyong["base"]["unit"] == "" + + +def test_없는_파일_표는_404_경로_넘기도_404(client: TestClient) -> None: + for params in ( + {"file": "nope", "table": "rows"}, + {"file": "rates", "table": "nope"}, + {"file": "../../config/config_db", "table": "rows"}, + {"file": "002yr_01hr", "table": "features"}, # 강우 IDF 캐시 — 갈래표 밖 + {"file": "_manifest", "table": "files"}, + ): + assert client.get("/api/master-data/rows", params=params).status_code == 404, params From 399b2193714336e7dc29da697a495a15fd92e60c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:09:35 +0900 Subject: [PATCH 05/48] =?UTF-8?q?feat(z01):=20=EC=A4=84=EC=97=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=97=B4=EC=9D=80=20=E3=80=8C=E2=80=94?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20columns=20=EB=B0=96=20=EC=97=B4=EC=9D=80?= =?UTF-8?q?=20=EC=95=88=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20main.py=20?= =?UTF-8?q?=EC=97=90=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=EC=9A=A9=20=EB=B3=B4=ED=98=B8=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=91=A0(=EB=9D=BC=EC=9A=B0=ED=84=B0=20import?= =?UTF-8?q?=C2=B7=EB=93=B1=EB=A1=9D=20=EB=91=90=20=EC=A4=84=EC=9D=80=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=B4=20=EC=98=AC=EB=9D=BC=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20=EC=A3=BC=EC=84=9D=20=ED=92=82=20=E2=80=94=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EB=AA=A8=EB=93=88=20import=20=EB=8A=94=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A5=BC=20=EB=AA=BB=20=EB=9D=84=EC=9B=80)(?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 7 +++++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 5 ++--- main.py | 7 +++++++ resources/tester/test_z01_master_cells.py | 9 ++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7669fe5e..7bc9a47f 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -33,6 +33,13 @@ export function cellText(value: unknown, unit?: string | null): string { return String(value); } +/** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */ +export function rowCells(row: Record, columns: MasterColumn[]): string[] { + return columns.map((column) => + Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—", + ); +} + export function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index 7d8fd84c..eccef02b 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -27,7 +27,7 @@ import { type MasterRows, type MasterTable, } from "./Z01_MasterData_Api_Fetch"; -import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells"; +import { columnTitle, pageCount, rowCells, shownColumns } from "./Z01_MasterData_UI_Cells"; import "./Z01_MasterData_UI_Style.css"; const PAGE_SIZE = 50; @@ -229,8 +229,7 @@ function buildRowsView(): RowsView { const body = el("tbody"); for (const row of last.rows) { const tr = el("tr"); - for (const column of columns) { - const text = cellText(row[column.key], column.unit); + for (const text of rowCells(row, columns)) { tr.append(el("td", { text, attrs: { title: text } })); } body.append(tr); diff --git a/main.py b/main.py index 45f4e487..07cf9a3b 100644 --- a/main.py +++ b/main.py @@ -79,6 +79,7 @@ from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, require_project_access, + require_system_admin, verify_session, ) @@ -654,6 +655,12 @@ app.include_router(b09_progress_router, dependencies=protected_with_company) 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(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 82c485e1..e9433950 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -4,6 +4,8 @@ ② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임 ③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로 ④ 쪽 수는 줄이 없어도 1 + ⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데 + 줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인) TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식). """ @@ -19,7 +21,9 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; -import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells.js"; +import { + cellText, columnTitle, pageCount, rowCells, shownColumns, +} from "./Z01_MasterData_UI_Cells.js"; const columns = [ { key: "id", label: "id", unit: null, hidden: true }, @@ -40,6 +44,7 @@ writeFileSync(process.argv[2], JSON.stringify({ cellText("보통인부", null), ], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], + row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), })); """ @@ -80,3 +85,5 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["titles"] == ["id", "직종명", "일 노임 (원)"] assert got["cells"] == ["", "250,000", "1.23456", "2026", '{"a":1}', "예", "보통인부"] assert got["pages"] == [1, 140, 1] + # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 + assert got["row"] == ["—", "", "250,000"] From 5e7ec39c6955ec1f362aff28b45b81f8bdb1c498 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:11:24 +0900 Subject: [PATCH 06/48] =?UTF-8?q?feat(z01):=20=ED=91=9C=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EC=9D=B4=EB=A6=84=ED=91=9C=20?= =?UTF-8?q?=EC=9E=A3=EB=8C=80=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=C2=B7=EA=B0=88=EB=9E=98?= =?UTF-8?q?=C2=B7=ED=95=9C=EA=B8=80=20=EC=9D=B4=EB=A6=84=C2=B7=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=C2=B7=EC=88=A8=EA=B9=80=EC=9D=80=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=EB=A7=8C(?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B0=88=EB=9E=98=ED=91=9C=20=EB=BA=8C=20?= =?UTF-8?q?=C2=B7=20=EB=91=90=20=EB=B2=8C=20=EA=B8=88=EC=A7=80)=20=C2=B7?= =?UTF-8?q?=20=ED=91=9C=20=3D=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=B4=20=EB=A7=88=EB=94=94(=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=ED=91=9C=EB=8A=94=20?= =?UTF-8?q?=EC=A4=84=EB=A7=88=EB=8B=A4=20@key)=20=C2=B7=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=ED=91=9C=20id=20=E2=86=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=ED=91=9C=20id=20=EC=96=91=EC=AA=BD=20=EC=85=88=200?= =?UTF-8?q?=20=C2=B7=2091=20=ED=91=9C=20=C2=B7=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=EC=A4=84=20=EC=88=98=EC=99=80=20=EA=B0=99=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20resources=20=EB=B0=96=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=95=88=20=EC=9D=BD=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_Tables.py | 329 ++++++++--------------- resources/tester/test_z01_master_data.py | 204 +++++++------- 2 files changed, 209 insertions(+), 324 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py index 0779b85f..1d544f90 100644 --- a/Z01_MasterData/Z01_MasterData_Tables.py +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -1,280 +1,163 @@ """Z01 마스터 데이터 — `resources/` 마스터 JSON 을 **갈래 → 파일 → 표 → 줄** 로 가름(읽기 전용). -마스터 = 로직 + 기초값(2026-09-15 사용자 방향 · bottom-up). 가르는 잣대 = 「값이 바뀌는 것 = 기초값 · 규칙·표 = 로직」(브레인). -표 가르기(브레인 승인): - · `variables` 칸은 변수 하나 = 표 하나 — 변수 안에 목록이 둘 이상이면 한 표에 잇고 `@part` 열에 목록 이름 - · 나머지는 줄 모음마다 표 하나 — 딕셔너리 목록 · 같은 길이 **수** 열(글 목록은 길이가 같아도 짝짓지 않음) · - 딕셔너리의 딕셔너리(`@key` 열) · 값 딕셔너리(`@key`·`@value` 줄) - · 설명(note·source·policy …)은 표가 아님 — 줄 안의 설명 칸은 그대로 둠 -한글 이름·단위는 **이름표 파일**(`resources/data_master_labels/*.json` · 데스크탑 서브 몫)에서만 — 없으면 영문 key. +마스터 = 로직 + 기초값(2026-09-15 사용자 방향 · bottom-up). +**이름표 파일**(`resources/data_master_labels/labels_*.json` · 데스크탑 서브)이 파일 목록·갈래·한글 이름·단위·보임의 자리 — +여기는 그 목록의 파일을 읽어 표를 찾고 줄을 냄. 갈래표를 코드에 또 두지 않음(두 벌 금지). +표 = **화면이 표로 그릴 마디** — 이름표 생성기와 같은 잣대(브레인 「91 이 정본」): + · 딕셔너리 목록(비지 않음) → 줄 = 딕셔너리 그대로({열key: 값}) + · 딕셔너리 둘 이상이 모인 딕셔너리(열이 비슷함) → 줄마다 `@key` 열(그 줄의 이름) + 값 딕셔너리 +⚠ 표 id 가 이름표와 한 글자라도 다르면 이름이 통째로 안 붙음 — `test_z01_master_data.py` 가 양쪽으로 셈. """ from __future__ import annotations import json -import re from functools import lru_cache from pathlib import Path from typing import Any -RESOURCES = Path(__file__).resolve().parent.parent / "resources" +ROOT = Path(__file__).resolve().parent.parent +RESOURCES = ROOT / "resources" LABEL_DIR = RESOURCES / "data_master_labels" DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 500 +ROW_KEY = "@key" -# 갈래 나눔표(브레인 2026-09-15) — 차례가 화면 차례. 여기 없는 파일(강우 IDF 캐시 등)은 안 보임. -GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("logic", ( - "pum_forest", "pum_const", # 품셈 원문 - "work_item_master", # 공종 축 - "coef", "material_surcharge", "formwork_reuse", "rebar_complexity", "timber_structure_class", - "masonry_class", "masonry_slope", "masonry_back_length", "stone_kind", "revetment_sabang", - "structure_unit_observed", # 계수 - "work_item_mapping", "aliases", "resource_catalog_ext", # 잇는 표 - )), - ("base", ( - "labor_const", "labor_mfg", "mach_base", "machine_operating", "mat_price_public", - "oil", "oil_regional", "fx", "rates", - )), - ("byproduct", ("basis_missing", "form_undetermined", "resource_axis", "unmatched")), # 정본 아님 — 기록 - ("seed", ("masonry_wet",)), -) # fmt: skip - -# 파일 머리 — 표가 아님 -_HEAD_KEYS = { - "source", "sources", "policy", "derived_from", "source_dataset_version", "source_master_file", - "dataset_version", "stats", "master", "parse_audit", "variables", +# 파일 머리 — 표도 값 묶음도 아님(이름표 생성기와 같은 목록) +META_KEYS = { + "schema_version", "dataset_id", "effective_date", "generated_at", "publication_date", "survey_month", + "pum_edition", "dataset_version", "source_master_file", "source_dataset_version", } # fmt: skip -_NOTE_KEYS = {"note", "notes", "source", "sources", "quote", "why", "policy"} -_HIDDEN_KEYS = {"sha256", "generated_at", "raw_row_index", "sort_order"} -_DATE_SUFFIX = re.compile(r"_\d{4}(?:-\d{2}-\d{2})?$") -def _is_note(key: str) -> bool: - return key in _NOTE_KEYS or key.endswith("_note") +def _is_table(node: Any) -> str | None: + if isinstance(node, list) and node and all(isinstance(r, dict) for r in node): + return "list" + if isinstance(node, dict): + values = list(node.values()) + dicts = [v for v in values if isinstance(v, dict)] + if ( + len(values) >= 2 + and len(dicts) == len(values) + and not any( + isinstance(v.get("records"), list) or isinstance(v.get("rows"), list) for v in dicts + ) + ): + keysets = {frozenset(v) for v in dicts} + if len(keysets) <= max(2, len(dicts) // 3 + 1): + return "map" + return None -def _scalar(v: Any) -> bool: - return not isinstance(v, (dict, list)) +def _has_table(node: Any, depth: int = 0) -> bool: + if _is_table(node): + return True + if depth > 5 or not isinstance(node, dict): + return False + return any(_has_table(v, depth + 1) for v in node.values()) -def _flat(d: dict, prefix: str = "") -> dict[str, Any]: - """줄 하나 — 안쪽 딕셔너리는 `a.b` 열로 펼치고 목록은 값 그대로.""" - out: dict[str, Any] = {} - for k, v in d.items(): - if isinstance(v, dict) and v: - out.update(_flat(v, f"{prefix}{k}.")) - else: - out[f"{prefix}{k}"] = v - return out - - -def _list_rows(items: list) -> list[dict[str, Any]]: - return [_flat(x) if isinstance(x, dict) else {"@value": x} for x in items] - - -def _numeric(v: Any) -> bool: - if isinstance(v, list): - return all(_numeric(x) and not isinstance(x, list) for x in v) - return v is None or (isinstance(v, (int, float)) and not isinstance(v, bool)) - - -def _columnar(d: dict) -> dict[str, list] | None: - """같은 길이 수 열(2개 이상) — 안쪽 한 겹 딕셔너리(`columns`)는 끌어올림.""" - cols: dict[str, list] = {} - for k, v in d.items(): - if _is_note(k): - continue - if isinstance(v, dict): - for ck, cv in v.items(): - if _is_note(ck): - continue - if ck in cols or not isinstance(cv, list): - return None - cols[ck] = cv - elif isinstance(v, list) and k not in cols: - cols[k] = v - else: - return None - lengths = {len(v) for v in cols.values()} - if len(cols) < 2 or len(lengths) != 1 or 0 in lengths: - return None - if not all(_numeric(x) for v in cols.values() for x in v): - return None - return cols - - -def _tableish(d: dict) -> bool: - """제 표로 떼어 낼 딕셔너리 — 같은 길이 수 열이거나 그런 것을 품음. 나머지는 한 줄로 펼침.""" - return _columnar(d) is not None or any( - isinstance(v, dict) and _tableish(v) for k, v in d.items() if not _is_note(k) - ) - - -def _dict_rows(d: dict) -> list[dict[str, Any]]: - """목록 없는 딕셔너리 — 안쪽 딕셔너리는 키마다 한 줄(`@key` + 펼친 칸), 값은 키·값 줄.""" - body = {k: v for k, v in d.items() if not _is_note(k)} - kv = [{"@key": k, "@value": v} for k, v in body.items() if not isinstance(v, dict)] - return kv + [{"@key": k, **_flat(v)} for k, v in body.items() if isinstance(v, dict)] - - -def _variable_rows(v: Any) -> list[dict[str, Any]]: - if isinstance(v, list): - return _list_rows(v) - lists = [(k, x) for k, x in v.items() if isinstance(x, list) and not _is_note(k)] - if not lists: - cols = _columnar(v) - return [dict(zip(cols, r)) for r in zip(*cols.values())] if cols else _dict_rows(v) - consts = _flat( - {k: x for k, x in v.items() if not _is_note(k) and (_scalar(x) or isinstance(x, dict))} - ) - # `key` = 어느 열이 키인지 적은 머리 — 줄 값이 아님 - consts = {k: x for k, x in consts.items() if k != "key" and not _is_note(k.rsplit(".", 1)[-1])} - return [ - {**consts, **({"@part": k} if len(lists) > 1 else {}), **row} - for k, items in lists - for row in _list_rows(items) - ] - - -def _collect(node: Any, path: str, out: list[tuple[str, list[dict[str, Any]]]]) -> None: - if isinstance(node, list): - out.append((path, _list_rows(node))) - return - cols = _columnar(node) - if cols: - out.append((path, [dict(zip(cols, r)) for r in zip(*cols.values())])) - return - body = {k: v for k, v in node.items() if not _is_note(k)} - lists = {k: v for k, v in body.items() if isinstance(v, list)} - if lists: # 목록마다 표 하나 · 곁의 값은 줄마다 붙임 - consts = {k: v for k, v in body.items() if _scalar(v)} - for k, v in lists.items(): - out.append((f"{path}/{k}", [{**consts, **r} for r in _list_rows(v)])) - for k, v in body.items(): - if isinstance(v, dict): - _collect(v, f"{path}/{k}", out) - return - split = [k for k, v in body.items() if isinstance(v, dict) and _tableish(v)] - rest = {k: v for k, v in body.items() if k not in split} - if rest: - out.append((path, _dict_rows(rest))) - for k in split: - _collect(body[k], f"{path}/{k}", out) - - -def file_id(path: Path) -> str: - return _DATE_SUFFIX.sub("", path.stem) - - -def master_files() -> dict[str, Path]: - """갈래표에 든 id → 파일(같은 id 가 여러 판이면 이름 차례로 끝 판).""" - wanted = {i for _, ids in GROUPS for i in ids} - found: dict[str, Path] = {} - for p in sorted( - [*RESOURCES.glob("data_*/*.json"), *RESOURCES.glob("library_structure/*.json")] - ): - if file_id(p) in wanted and p.parent != LABEL_DIR: - found[file_id(p)] = p - return found +def _collect(node: Any, path: list[str], out: dict[str, list], depth: int = 0) -> None: + shape = _is_table(node) + if shape == "list": + out["/".join(path)] = node + elif shape == "map": + out["/".join(path)] = [{ROW_KEY: k, **v} for k, v in node.items()] + elif depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()): + for key, value in node.items(): + _collect(value, [*path, key], out, depth + 1) @lru_cache(maxsize=64) -def _tables_of(path: str, _mtime_ns: int) -> tuple[tuple[str, tuple[dict[str, Any], ...]], ...]: - data = json.loads(Path(path).read_text(encoding="utf-8")) - out: list[tuple[str, list[dict[str, Any]]]] = [] - for name, v in (data.get("variables") or {}).items(): - out.append((f"variables/{name}", _variable_rows(v))) - for k, v in data.items(): - if k not in _HEAD_KEYS and not _is_note(k) and not _scalar(v): - _collect(v, k, out) - return tuple((tid, tuple(rows)) for tid, rows in out) +def _tables_of(path: str, _mtime_ns: int) -> dict[str, list[dict[str, Any]]]: + doc = json.loads(Path(path).read_text(encoding="utf-8")) + out: dict[str, list[dict[str, Any]]] = {} + for key, value in doc.items(): + if key not in META_KEYS: + _collect(value, [key], out) + return out -def tables_of(path: Path) -> dict[str, tuple[dict[str, Any], ...]]: - return dict(_tables_of(str(path), path.stat().st_mtime_ns)) +def tables_of(path: Path) -> dict[str, list[dict[str, Any]]]: + """표 id(이름표의 표 key · `/` 로 이은 자리) → 줄.""" + return _tables_of(str(path), path.stat().st_mtime_ns) -def load_labels() -> dict[str, dict[str, Any]]: - """이름표 파일 모두를 한 벌로 — 부를 때마다 읽음(작은 파일 · 고치면 바로 보임).""" - merged: dict[str, dict[str, Any]] = {"groups": {}, "files": {}, "tables": {}, "columns": {}} - for p in sorted(LABEL_DIR.glob("*.json")) if LABEL_DIR.is_dir() else []: - data = json.loads(p.read_text(encoding="utf-8")) - for section, entries in merged.items(): - entries.update(data.get(section) or {}) - return merged +@lru_cache(maxsize=4) +def _labels_at(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) -def _text(entry: Any, key: str = "label") -> str: - if isinstance(entry, dict): - return str(entry.get(key) or "") - return str(entry or "") if key == "label" else "" +def load_labels() -> dict[str, Any]: + """이름표 끝 판(이름 차례) — 없으면 빈 이름표(파일도 안 보임).""" + found = sorted(LABEL_DIR.glob("labels_*.json")) if LABEL_DIR.is_dir() else [] + if not found: + return {"kinds": {}, "files": [], "columns": {}, "column_overrides": {}} + return _labels_at(str(found[-1]), found[-1].stat().st_mtime_ns) + + +def _file_path(entry: dict[str, Any]) -> Path | None: + path = (ROOT / str(entry.get("path") or "")).resolve() + return path if path.is_file() and path.is_relative_to(RESOURCES) else None def tree() -> dict[str, Any]: labels = load_labels() - files = master_files() - groups = [] - for gkey, ids in GROUPS: - entries = [] - for fid in ids: - if fid not in files: - continue - entries.append( - { - "id": fid, - "label": _text(labels["files"].get(fid)) or fid, - "key": files[fid].name, - "tables": [ - { - "id": tid, - "label": _text(labels["tables"].get(f"{fid}/{tid}")) - or tid.rsplit("/", 1)[-1], - "key": tid.rsplit("/", 1)[-1], - "row_count": len(rows), - } - for tid, rows in tables_of(files[fid]).items() - ], - } - ) - groups.append( - {"key": gkey, "label": _text(labels["groups"].get(gkey)) or gkey, "files": entries} + groups = { + k: {"key": k, "label": v.get("name_ko") or k, "files": []} + for k, v in labels["kinds"].items() + } + for entry in labels["files"]: + path = _file_path(entry) + if path is None: + continue + named = {t["key"]: t.get("name_ko") or "" for t in entry.get("tables") or []} + kind = entry.get("kind") or "" + group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []}) + group["files"].append( + { + "id": entry["file_id"], + "label": entry.get("name_ko") or entry["file_id"], + "key": path.name, + "tables": [ + {"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": len(rows)} + for tid, rows in tables_of(path).items() + ], + } ) - return {"groups": groups} + return {"groups": [g for g in groups.values() if g["files"]]} def rows( fid: str, tid: str, page: int = 1, size: int = DEFAULT_PAGE_SIZE, q: str = "" ) -> dict[str, Any] | None: """표 줄 한 쪽 — 없는 파일·표는 None. 검색은 줄 값 글자에 든 것(대소문자 무시).""" - path = master_files().get(fid) + labels = load_labels() + entry = next((f for f in labels["files"] if f["file_id"] == fid), None) + path = _file_path(entry) if entry else None table = tables_of(path).get(tid) if path else None if table is None: return None - columns: dict[str, None] = {} + keys: dict[str, None] = {} for r in table: - columns.update(dict.fromkeys(r)) + keys.update(dict.fromkeys(r)) needle = q.strip().lower() hits = ( [r for r in table if needle in json.dumps(r, ensure_ascii=False).lower()] if needle - else list(table) + else table ) size = max(1, min(size, MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size - col_labels = load_labels()["columns"] - out_cols = [] - for key in columns: - entry = col_labels.get(f"{fid}/{key}") or col_labels.get(key) - hidden = entry.get("hidden") if isinstance(entry, dict) and "hidden" in entry else None - out_cols.append( + columns = [] + for key in keys: # 찾는 차례: 「파일id/열key」 → 「열key」 → 영문 key + named = labels["column_overrides"].get(f"{fid}/{key}") or labels["columns"].get(key) or {} + columns.append( { "key": key, - "label": _text(entry) or key, - "unit": _text(entry, "unit"), - "hidden": bool(hidden) - if hidden is not None - else key.rsplit(".", 1)[-1] in _HIDDEN_KEYS, + "label": named.get("name_ko") or key, + "unit": named.get("unit") or "", + "hidden": named.get("visible") is False, } ) - return {"columns": out_cols, "rows": hits[start : start + size], "total": len(hits)} + return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)} diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index 3fbd0891..8be5f053 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -1,9 +1,8 @@ """Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판. -약속(브레인 승인): 갈래 넷 로직 17 · 기초값 9 · 부산물 4 · 씨앗 1 = 31 파일 · 강우 IDF 캐시는 뺌 · -variables 칸은 변수 하나 = 표 하나 · 나머지는 줄 모음(목록·같은 길이 수 열)마다 표 하나 · 설명(note·source·policy)은 표 아님 · -한글 이름은 이름표 파일(`resources/data_master_labels/`)에서만 — 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · -unit 도 이름표에서만(자료에서 지어내지 않음) · 숨김 = 내부 id · sha · 생성시각. +약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 · +표 = 화면이 표로 그릴 마디(이름표 91 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 · +열 이름 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · 줄 한 줄 = {열key: 값} · file·table 은 트리의 id. """ from __future__ import annotations @@ -18,25 +17,13 @@ from fastapi.testclient import TestClient from Z01_MasterData import Z01_MasterData_Router as router_module from Z01_MasterData import Z01_MasterData_Tables as tables -GROUPS = { - "logic": { - "pum_forest", "pum_const", "work_item_master", "coef", "material_surcharge", "formwork_reuse", - "rebar_complexity", "timber_structure_class", "masonry_class", "masonry_slope", "masonry_back_length", - "stone_kind", "revetment_sabang", "structure_unit_observed", "work_item_mapping", "aliases", - "resource_catalog_ext", - }, - "base": { - "labor_const", "labor_mfg", "mach_base", "machine_operating", "mat_price_public", "oil", "oil_regional", - "fx", "rates", - }, - "byproduct": {"basis_missing", "form_undetermined", "resource_axis", "unmatched"}, - "seed": {"masonry_wet"}, -} # fmt: skip +LABELS = json.loads( + (tables.LABEL_DIR / "labels_2026-01-01.json").read_text(encoding="utf-8") +) # 서브 이름표 판 @pytest.fixture -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setattr(tables, "LABEL_DIR", tmp_path / "labels") # 이름표 없음 — 영문 key 그대로 +def client() -> TestClient: app = FastAPI() app.include_router(router_module.router) return TestClient(app) @@ -54,120 +41,135 @@ def _rows(client: TestClient, **params) -> dict: return res.json() -def test_갈래_넷에_파일_31_강우_캐시는_없음(client: TestClient) -> None: +def _files(client: TestClient) -> dict[str, dict]: + return {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]} + + +def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: TestClient) -> None: groups = _tree(client)["groups"] - assert [g["key"] for g in groups] == ["logic", "base", "byproduct", "seed"] - for g in groups: - assert {f["id"] for f in g["files"]} == GROUPS[g["key"]], g["key"] - assert g["label"] == g["key"] # 이름표가 없으면 영문 key - for f in g["files"]: - assert f["label"] == f["id"] and f["key"].endswith(".json") and f["tables"], f["id"] - ids = [f["id"] for g in groups for f in g["files"]] - assert len(ids) == 31 and not any("idf" in i or "rainfall" in i for i in ids) + assert [g["key"] for g in groups] == [k for k in LABELS["kinds"]] + counts = {g["key"]: len(g["files"]) for g in groups} + assert counts == {"logic": 17, "base_value": 9, "byproduct": 4, "fingerprint": 3, "seed": 1} + assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값" + by_id = {f["file_id"]: f for f in LABELS["files"]} + for f in (f for g in groups for f in g["files"]): + assert ( + f["label"] == by_id[f["id"]]["name_ko"] + and f["key"] == Path(by_id[f["id"]]["path"]).name + ) + assert not [ + f for f in _files(client).values() if "rainfall" in f["key"] or f["key"][:3] == "002" + ] -def test_variables_는_변수_하나가_표_하나(client: TestClient) -> None: - files = {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]} - rates = [t for t in files["rates"]["tables"] if t["id"].startswith("variables/")] - assert len(rates) == 19, [t["id"] for t in rates] - vat = next(t for t in rates if t["id"] == "variables/rate_vat") - assert ( - vat["key"] == "rate_vat" and vat["label"] == "rate_vat" and vat["row_count"] == 2 - ) # rate_percent · base - assert len([t for t in files["coef"]["tables"] if t["id"].startswith("variables/")]) == 5 - assert "processing_rules" in {t["id"] for t in files["rates"]["tables"]} # 변수 밖 규칙도 표 - # 한 변수에 목록이 둘이면 한 표에 잇고 어느 목록인지 열로 가름 - fuel = _rows(client, file="mach_base", table="variables/mach_fuel_rate", size=500) - assert fuel["total"] == 214 + 21 - assert {r["@part"] for r in fuel["rows"]} >= {"parsed_records"} +def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: TestClient) -> None: + files = _files(client) + not_labelled, label_only = [], [] + for entry in LABELS["files"]: + tree_ids = {t["id"] for t in files[entry["file_id"]]["tables"]} + label_ids = {t["key"] for t in entry["tables"]} + not_labelled += [f"{entry['file_id']}::{i}" for i in sorted(tree_ids - label_ids)] + label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)] + assert not_labelled == [], not_labelled + assert label_only == [], label_only + assert sum(len(f["tables"]) for f in files.values()) == LABELS["counts"]["tables"] == 91 + for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음 + named = {t["key"]: t for t in entry["tables"]} + for t in files[entry["file_id"]]["tables"]: + assert t["label"] == named[t["id"]]["name_ko"], (entry["file_id"], t["id"]) + assert t["row_count"] == named[t["id"]]["rows"], (entry["file_id"], t["id"]) def test_자재_단가는_쪽으로_나눠_주고_검색으로_좁힘(client: TestClient) -> None: - first = _rows(client, file="mat_price_public", table="variables/mat_price", page=1, size=50) - second = _rows(client, file="mat_price_public", table="variables/mat_price", page=2, size=50) + table = "variables/mat_price/records" + first = _rows(client, file="mat_price_public", table=table, page=1, size=50) + second = _rows(client, file="mat_price_public", table=table, page=2, size=50) assert first["total"] == 6999 and len(first["rows"]) == 50 and first["rows"] != second["rows"] - assert {c["key"] for c in first["columns"]} >= {"item_code", "price_krw", "specification"} - assert "key" not in { - c["key"] for c in first["columns"] - } # 「어느 열이 키인지」 머리는 줄 값이 아님 - found = _rows( - client, file="mat_price_public", table="variables/mat_price", q="육각볼트", size=500 - ) + raw = json.loads( + Path( + tables.ROOT / "resources/data_cost_input_value/mat_price_public_2026-08-14.json" + ).read_text(encoding="utf-8") + )["variables"]["mat_price"]["records"] + assert first["rows"][0] == raw[0] and second["rows"][0] == raw[50] # 한 줄 = {열key: 값} 그대로 + found = _rows(client, file="mat_price_public", table=table, q="육각볼트", size=500) assert 0 < found["total"] < 6999 assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in found["rows"]) - capped = _rows(client, file="mat_price_public", table="variables/mat_price", size=100000) + capped = _rows(client, file="mat_price_public", table=table, size=100000) assert len(capped["rows"]) == tables.MAX_PAGE_SIZE -def test_같은_길이_수_열은_줄로_세움(client: TestClient) -> None: - slope = _rows(client, file="masonry_slope", table="table/메쌓기") - assert slope["total"] == 5 and slope["rows"][0] == {"성토": 0.3, "절토": 0.25} - wedge = _rows(client, file="masonry_wet", table="tables/고임돌표") - assert wedge["total"] == 7 and wedge["rows"][0]["keys"] == 25 - # 글 목록 둘은 길이가 같아도 짝짓지 않음(자재 이름 ↔ 뒤진 자리) - files = {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]} - ids = {t["id"] for t in files["material_surcharge"]["tables"]} - assert {"not_found/materials", "not_found/checked"} <= ids +def test_묶음_표는_줄마다_이름_열(client: TestClient) -> None: + slope = _rows(client, file="masonry_slope", table="table") + assert [r["@key"] for r in slope["rows"]] == ["메쌓기", "찰쌓기"] + assert slope["rows"][0]["성토"] == [0.3, 0.35, 0.4, 0.45, 0.5] + cols = {c["key"]: c for c in slope["columns"]} + assert cols["성토"]["label"] == "성토부 경사" and cols["성토"]["unit"] == "1:n" + fx = {c["key"]: c for c in _rows(client, file="fx", table="variables")["columns"]} + assert fx["value"]["label"] == "환율" and fx["value"]["unit"] == "원" # 「fx/value」 덮어쓰기 -def test_원문_표는_번호로_찾음(client: TestClient) -> None: - hit = _rows(client, file="pum_forest", table="variables/pum", q="F0155") +def test_숨김은_이름표의_보임에서(client: TestClient) -> None: + cols = {c["key"]: c for c in _rows(client, file="pum_forest", table="sources")["columns"]} + assert cols["sha256"]["hidden"] is True and cols["path"]["hidden"] is False + hit = _rows(client, file="pum_forest", table="variables/pum/tables", q="F0155") assert [r["table_id"] for r in hit["rows"]] == ["F0155"] -def test_내부_id_sha_생성시각은_숨김(client: TestClient) -> None: - axis = _rows(client, file="resource_axis", table="rows", size=1) - hidden = {c["key"]: c["hidden"] for c in axis["columns"]} - assert hidden["raw_row_index"] is True and hidden["resource_name"] is False - master = _rows(client, file="work_item_master", table="work_items", size=1) - assert {c["key"]: c["hidden"] for c in master["columns"]}["sort_order"] is True - assert all(c["unit"] == "" and c["label"] == c["key"] for c in axis["columns"]) # 이름표 없음 - - -def test_이름표_파일이_있으면_label_unit_을_붙임(client: TestClient, tmp_path: Path) -> None: - labels = tmp_path / "labels" - labels.mkdir() - (labels / "labels.json").write_text( +def test_찾는_차례와_없는_이름은_영문_key( + client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "labels_2026-01-01.json").write_text( json.dumps( { - "groups": {"base": "기초값"}, - "files": {"rates": "요율"}, - "tables": {"rates/variables/rate_vat": "부가가치세"}, + "kinds": {"base_value": {"name_ko": "기초값"}}, + "files": [ + { + "file_id": "rates", + "path": "resources/data_cost_input_value/rates_2026.json", + "kind": "base_value", + "name_ko": "", + "tables": [], + }, + {"file_id": "outside", "path": "config/config_db.py", "kind": "base_value"}, + ], "columns": { - "base": {"label": "밑수"}, - "rates/rate_percent": {"label": "요율", "unit": "%"}, - "rate_percent": {"label": "딴 이름", "unit": "딴 단위"}, + "rate_percent": {"name_ko": "딴 이름", "unit": "딴 단위", "visible": True}, + "grade": {"name_ko": "등급", "unit": "", "visible": False}, }, + "column_overrides": {"rates/rate_percent": {"name_ko": "요율", "unit": "%"}}, }, ensure_ascii=False, ), encoding="utf-8", ) - base = next(g for g in _tree(client)["groups"] if g["key"] == "base") - rates = next(f for f in base["files"] if f["id"] == "rates") - assert base["label"] == "기초값" and rates["label"] == "요율" - assert ( - next(t for t in rates["tables"] if t["id"] == "variables/rate_vat")["label"] == "부가가치세" - ) - cols = {c["key"]: c for c in _rows(client, file="rates", table="variables/rate_vat")["columns"]} - assert cols["@key"]["label"] == "@key" # 없는 것은 영문 key - vat = _rows(client, file="rates", table="variables/rate_sanjae") - assert {r["@key"] for r in vat["rows"]} == {"rate_percent", "base"} - goyong = { - c["key"]: c for c in _rows(client, file="rates", table="variables/rate_goyong")["columns"] + monkeypatch.setattr(tables, "LABEL_DIR", tmp_path) + groups = _tree(client)["groups"] + assert [f["id"] for g in groups for f in g["files"]] == ["rates"] # resources 밖은 안 읽음 + rates = groups[0]["files"][0] + assert rates["label"] == "rates" + goyong = next(t for t in rates["tables"] if t["id"] == "variables/rate_goyong/brackets") + assert goyong["label"] == goyong["id"] + cols = {c["key"]: c for c in _rows(client, file="rates", table=goyong["id"])["columns"]} + assert cols["rate_percent"]["label"] == "요율" and cols["rate_percent"]["unit"] == "%" + assert cols["grade"]["label"] == "등급" and cols["grade"]["hidden"] is True + assert cols["estimated_amount_bracket"] == { + "key": "estimated_amount_bracket", + "label": "estimated_amount_bracket", + "unit": "", + "hidden": False, } assert ( - goyong["rate_percent"]["label"] == "요율" and goyong["rate_percent"]["unit"] == "%" - ) # 파일 것이 이김 - assert goyong["base"]["label"] == "밑수" and goyong["base"]["unit"] == "" + client.get("/api/master-data/rows", params={"file": "outside", "table": "x"}).status_code + == 404 + ) -def test_없는_파일_표는_404_경로_넘기도_404(client: TestClient) -> None: +def test_없는_파일_표는_404(client: TestClient) -> None: for params in ( {"file": "nope", "table": "rows"}, {"file": "rates", "table": "nope"}, + {"file": "rates", "table": "variables/rate_sanjae"}, # 값 묶음 — 표 아님 {"file": "../../config/config_db", "table": "rows"}, - {"file": "002yr_01hr", "table": "features"}, # 강우 IDF 캐시 — 갈래표 밖 - {"file": "_manifest", "table": "files"}, + {"file": "002yr_01hr", "table": "features"}, # 강우 IDF 캐시 — 이름표 밖 ): assert client.get("/api/master-data/rows", params=params).status_code == 404, params From 05c9d1e1e66b66c6a2674f387132f002db739aba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:14:02 +0900 Subject: [PATCH 07/48] =?UTF-8?q?feat(z01):=20main.py=20=EC=97=90=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EB=93=B1=EB=A1=9D(=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20+=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=B3=B4=ED=98=B8=20=EC=97=86=EC=9D=8C)=20=C2=B7?= =?UTF-8?q?=20=ED=81=B0=20=ED=91=9C=20=EC=AA=BD=20=EB=84=98=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EC=B2=98=EC=9D=8C=C2=B7=EB=81=9D=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=EC=99=80=20=EC=AA=BD=20=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=B9=B8(1~=EB=81=9D=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=EC=9E=84)?= =?UTF-8?q?=20=C2=B7=20=EC=B5=9C=EC=8B=A0=20=EC=9D=91=EB=8B=B5=EB=A7=8C(?= =?UTF-8?q?=EB=8A=A6=EA=B2=8C=20=EC=98=A8=20=EC=98=9B=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=C2=B7=EC=98=9B=20=EC=98=A4=EB=A5=98=20=EB=B2=84=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 25 +++++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 71 +++++++++++-------- Z01_MasterData/Z01_MasterData_UI_Style.css | 5 ++ main.py | 6 +- resources/tester/test_z01_master_cells.py | 24 ++++++- .../tester/test_z01_master_registration.py | 32 +++++++++ ui_template/ui_template_locale_b3.ts | 2 + 7 files changed, 132 insertions(+), 33 deletions(-) create mode 100644 resources/tester/test_z01_master_registration.py diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7bc9a47f..7b170044 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -43,3 +43,28 @@ export function rowCells(row: Record, 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( + fn: (...args: A) => Promise, +): (...args: A) => Promise { + 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; + } + }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index eccef02b..b87be416 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -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[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 { 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 { diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index 7a37171a..a0c0a9aa 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -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; diff --git a/main.py b/main.py index 07cf9a3b..85f2344a 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index e9433950..d96a9299 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -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" diff --git a/resources/tester/test_z01_master_registration.py b/resources/tester/test_z01_master_registration.py new file mode 100644 index 00000000..0370159a --- /dev/null +++ b/resources/tester/test_z01_master_registration.py @@ -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 diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 67183d6a..8194f52d 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -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"], From da2238505c6643c6142073c5af35242d85754b45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:17:28 +0900 Subject: [PATCH 08/48] =?UTF-8?q?feat(z01):=20=EC=9D=B4=EB=A6=84=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=A4=84=20=EC=9D=B4=EB=A6=84=20=EC=97=B4=20?= =?UTF-8?q?=EC=97=AC=EB=8D=9F=EA=B3=BC=20=E3=80=8C=EA=B0=92=C2=B7=EC=84=A4?= =?UTF-8?q?=EB=AA=85=E3=80=8D=20=EA=B0=80=EB=A6=84=20172=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사전 모양 표 8 에 **줄 이름 열 `@key`** 를 둠(브레인 ①) — 표마다 제 이름: fx 「통화」 · oil 「유종」 · masonry_slope 「쌓기 방식」 · material_surcharge 「구실」 · stone_kind·structure_unit 「출처 키」 · masonry_wet 「표 이름」·「칸 키」 · `is_row_key` 표시 붙임 - 값 묶음 172 를 **값 49 · 설명 123** 으로 가름(브레인 ②) — 잣대는 「고치면 금액이 움직이나」 · 이름표 `policy.value_role_rule` 에 적음 · 갈래 뜻은 `value_roles` 에 - 값으로 선 것 — 한 값짜리 요율 여덟(산재·건강·요양·부가세·퇴직공제·임금채권·석면·안전관리비 기초액) · 요율마다의 밑수 · 시세 · 계수·원단위표(돌쌓기·거푸집·철근) · 잇는 값(노임 별칭·타설·배수관) - 설명으로 숨는 것 — 방침·출처·비고·읽기 기록과 표 머리의 키·단위·기준일 - 시험 25건(7건 늘) — 줄 이름 열이 빠지거나 한 값짜리 요율이 설명으로 숨으면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 596 ++++++++++++------ resources/tester/test_master_labels_cover.py | 90 ++- 2 files changed, 509 insertions(+), 177 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 4e79b5b9..ae9fbe4b 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T18:59:13+09:00", + "generated_at": "2026-09-15T19:16:20+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -11,7 +11,9 @@ "counting_rule": "표 = 화면이 표로 그릴 마디(줄이 여럿인 마디). 값 묶음 = 표가 아닌 마디(설명·방침·한 값짜리 요율 따위).", "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", - "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다." + "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", + "row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).", + "value_role_rule": "값 묶음은 갈래가 둘이다. **값** = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. **설명** = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·표 머리의 키·단위·기준일) — 낱값 마디에 **안 보인다**. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명(2026-09-15 브레인 ②)." }, "lookup_order": { "column": [ @@ -47,12 +49,26 @@ "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." } }, + "value_roles": { + "value": { + "name_ko": "값", + "summary": "원가·수량에 그대로 드는 값. 낱값 마디에 **보인다** — 여기서 사라지면 트리에서 값이 사라진다." + }, + "note": { + "name_ko": "설명", + "summary": "읽으라고 적어 둔 글(방침·출처·비고·기록). 낱값 마디에 **안 보인다** — 보이면 화면이 흐려진다." + } + }, "counts": { "files": 34, "tables": 91, - "columns": 447, + "columns": 455, "value_groups": 172, - "unknown": 0 + "unknown": 0, + "value_groups_by_role": { + "note": 123, + "value": 49 + } }, "files": [ { @@ -133,12 +149,14 @@ { "key": "variables/pum/normalization_status", "name_ko": "정돈 상태", - "summary": "자원 코드 정돈이 끝났는지." + "summary": "자원 코드 정돈이 끝났는지.", + "role": "note" }, { "key": "variables/pum/representation", "name_ko": "담은 꼴", - "summary": "원문 표를 어떤 꼴로 담았는지." + "summary": "원문 표를 어떤 꼴로 담았는지.", + "role": "note" } ] }, @@ -226,12 +244,14 @@ { "key": "variables/pum/normalization_status", "name_ko": "정돈 상태", - "summary": "자원 코드 정돈이 끝났는지." + "summary": "자원 코드 정돈이 끝났는지.", + "role": "note" }, { "key": "variables/pum/representation", "name_ko": "담은 꼴", - "summary": "원문 표를 어떤 꼴로 담았는지." + "summary": "원문 표를 어떤 꼴로 담았는지.", + "role": "note" } ] }, @@ -343,12 +363,14 @@ { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "stats", "name_ko": "집계", - "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", + "role": "note" } ] }, @@ -553,57 +575,68 @@ { "key": "variables/coef_soil_C/duplicate_note", "name_ko": "원문 중복 비고", - "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리." + "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리.", + "role": "note" }, { "key": "variables/coef_soil_C/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/coef_soil_L/duplicate_note", "name_ko": "원문 중복 비고", - "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리." + "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리.", + "role": "note" }, { "key": "variables/coef_soil_L/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/rate_tool", "name_ko": "공구손료율", - "summary": "주요 재료비에 곱하는 공구손료 율(하한·상한). 어느 값을 쓸지는 고름이 필요하다." + "summary": "주요 재료비에 곱하는 공구손료 율(하한·상한). 어느 값을 쓸지는 고름이 필요하다.", + "role": "value" }, { "key": "variables/surcharge_labor/application_status", "name_ko": "적용 상태", - "summary": "쓰려면 무엇이 더 정해져야 하는지." + "summary": "쓰려면 무엇이 더 정해져야 하는지.", + "role": "note" }, { "key": "variables/surcharge_labor/representation", "name_ko": "담은 꼴", - "summary": "원문 표를 어떤 꼴로 담았는지." + "summary": "원문 표를 어떤 꼴로 담았는지.", + "role": "note" }, { "key": "variables/surcharge_labor/unit", "name_ko": "값 단위", - "summary": "할증은 %다." + "summary": "할증은 %다.", + "role": "note" }, { "key": "variables/surcharge_mat/duplicate_application_prohibited", "name_ko": "중복 적용 금지", - "summary": "두 자리에 걸면 두 번 세게 된다는 표시." + "summary": "두 자리에 걸면 두 번 세게 된다는 표시.", + "role": "note" }, { "key": "variables/surcharge_mat/representation", "name_ko": "담은 꼴", - "summary": "원문 표를 어떤 꼴로 담았는지." + "summary": "원문 표를 어떤 꼴로 담았는지.", + "role": "note" }, { "key": "variables/surcharge_mat/unit", "name_ko": "값 단위", - "summary": "할증은 %다." + "summary": "할증은 %다.", + "role": "note" } ] }, @@ -717,6 +750,13 @@ "shape": "map", "rows": 2, "columns": [ + { + "key": "@key", + "name_ko": "구실", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "doc", "name_ko": "문서", @@ -742,22 +782,26 @@ { "key": "candidates_pending_user/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "not_found", "name_ko": "못 찾은 것", - "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것." + "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것.", + "role": "note" }, { "key": "observed_practice", "name_ko": "실무 관측", - "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다." + "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다.", + "role": "value" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" } ] }, @@ -899,47 +943,56 @@ { "key": "euroform_type/material_unit_note", "name_ko": "자재 밑수 비고", - "summary": "자재 환산을 어느 창이 하는지." + "summary": "자재 환산을 어느 창이 하는지.", + "role": "note" }, { "key": "euroform_type/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "euroform_type/reuse_note", "name_ko": "사용횟수 비고", - "summary": "닮은 두 사용횟수를 하나로 잇지 않는 까닭." + "summary": "닮은 두 사용횟수를 하나로 잇지 않는 까닭.", + "role": "note" }, { "key": "euroform_type/source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "reuse_ratio_pct", "name_ko": "사용횟수별 기준수량 비율", - "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다." + "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다.", + "role": "value" }, { "key": "shoring", "name_ko": "동바리", - "summary": "강관동바리가 어느 구조물에 서는지." + "summary": "강관동바리가 어느 구조물에 서는지.", + "role": "value" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" } ] }, @@ -1027,22 +1080,26 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "price_hint_krw_per_ton", "name_ko": "단가 참고값", - "summary": "표시 전용 — 갈래 차이를 사람이 보라고 둔 값. 계산에 안 들어간다." + "summary": "표시 전용 — 갈래 차이를 사람이 보라고 둔 값. 계산에 안 들어간다.", + "role": "value" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" } ] }, @@ -1136,22 +1193,26 @@ { "key": "basis_unit", "name_ko": "밑수", - "summary": "값이 무엇 하나당인지." + "summary": "값이 무엇 하나당인지.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "pending_user", "name_ko": "사용자 확정 대기", - "summary": "아직 사람이 답해야 닫히는 물음." + "summary": "아직 사람이 답해야 닫히는 물음.", + "role": "note" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" } ] }, @@ -1188,37 +1249,44 @@ { "key": "back_length/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "back_length/option_key", "name_ko": "고르개 키", - "summary": "화면 고르개가 쓰는 저장 칸 이름." + "summary": "화면 고르개가 쓰는 저장 칸 이름.", + "role": "value" }, { "key": "back_length/source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" }, { "key": "bond", "name_ko": "쌓기 방식", - "summary": "메쌓기·찰쌓기로 공종코드가 갈린다." + "summary": "메쌓기·찰쌓기로 공종코드가 갈린다.", + "role": "value" }, { "key": "boulder_diameter", "name_ko": "돌 직경 갈래", - "summary": "큰돌쌓기 — 저장 제원 stone_cm 으로 갈린다." + "summary": "큰돌쌓기 — 저장 제원 stone_cm 으로 갈린다.", + "role": "value" }, { "key": "face_slope", "name_ko": "전면 기울기", - "summary": "형식마다 정해진 1:n. 코드 기본 0.3 이 여기서 왔다." + "summary": "형식마다 정해진 1:n. 코드 기본 0.3 이 여기서 왔다.", + "role": "value" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -1236,6 +1304,13 @@ "shape": "map", "rows": 2, "columns": [ + { + "key": "@key", + "name_ko": "쌓기 방식", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "성토", "name_ko": "성토부 경사", @@ -1255,27 +1330,32 @@ { "key": "not_here", "name_ko": "이 표가 안 다루는 것", - "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" }, { "key": "steps_m", "name_ko": "직고 칸", - "summary": "표가 갈리는 높이 경계(m)." + "summary": "표가 갈리는 높이 경계(m).", + "role": "value" } ] }, @@ -1290,32 +1370,38 @@ { "key": "not_here", "name_ko": "이 표가 안 다루는 것", - "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" }, { "key": "steps_m", "name_ko": "직고 칸", - "summary": "표가 갈리는 높이 경계(m)." + "summary": "표가 갈리는 높이 경계(m).", + "role": "value" }, { "key": "table_cm", "name_ko": "뒷길이 범위표", - "summary": "메·찰마다 직고 칸별 뒷길이 하한·상한(㎝)." + "summary": "메·찰마다 직고 칸별 뒷길이 하한·상한(㎝).", + "role": "value" } ] }, @@ -1333,6 +1419,13 @@ "shape": "map", "rows": 4, "columns": [ + { + "key": "@key", + "name_ko": "출처 키", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "doc", "name_ko": "문서", @@ -1358,67 +1451,80 @@ { "key": "back_lengths_cm", "name_ko": "뒷길이 규격", - "summary": "표가 값을 주는 뒷길이 일곱 칸." + "summary": "표가 값을 주는 뒷길이 일곱 칸.", + "role": "value" }, { "key": "backfill_ratio_of_back_length", "name_ko": "뒤채움 몫", - "summary": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이다." + "summary": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이다.", + "role": "value" }, { "key": "fallback", "name_ko": "미지정일 때", - "summary": "사용자가 안 고른 경우 쓰는 한 벌." + "summary": "사용자가 안 고른 경우 쓰는 한 벌.", + "role": "value" }, { "key": "fill_concrete_m3_per_m2", "name_ko": "채움 콘크리트 원단위", - "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. 원문이 두 줄뿐이다." + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. 원문이 두 줄뿐이다.", + "role": "value" }, { "key": "kinds", "name_ko": "돌 종류", - "summary": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌 네 줄." + "summary": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌 네 줄.", + "role": "value" }, { "key": "no_folding", "name_ko": "접지 않음 규칙", - "summary": "표에 없는 뒷길이를 가까운 칸으로 접지 않는다 — 접으면 값이 조용히 틀린다." + "summary": "표에 없는 뒷길이를 가까운 칸으로 접지 않는다 — 접으면 값이 조용히 틀린다.", + "role": "note" }, { "key": "not_here", "name_ko": "이 표가 안 다루는 것", - "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리." + "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "option_key", "name_ko": "고르개 키", - "summary": "화면 고르개가 쓰는 저장 칸 이름." + "summary": "화면 고르개가 쓰는 저장 칸 이름.", + "role": "value" }, { "key": "option_note", "name_ko": "고르개 비고", - "summary": "고르개를 만들 때 챙길 것." + "summary": "고르개를 만들 때 챙길 것.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "wedge_stone_m3_per_m2", "name_ko": "고임돌 원단위", - "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. null 은 원문 「-」다." + "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. null 은 원문 「-」다.", + "role": "value" }, { "key": "why", "name_ko": "까닭", - "summary": "이 벌을 왜 따로 두는지." + "summary": "이 벌을 왜 따로 두는지.", + "role": "note" } ] }, @@ -1433,22 +1539,26 @@ { "key": "items", "name_ko": "항목", - "summary": "이 벌이 담는 항목 묶음." + "summary": "이 벌이 담는 항목 묶음.", + "role": "value" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "rank", "name_ko": "우선순위", - "summary": "원문끼리 어긋날 때 어느 쪽을 먼저 보는지." + "summary": "원문끼리 어긋날 때 어느 쪽을 먼저 보는지.", + "role": "note" }, { "key": "scope", "name_ko": "걸치는 범위", - "summary": "어디까지 쓰는 값인지." + "summary": "어디까지 쓰는 값인지.", + "role": "note" } ] }, @@ -1670,6 +1780,13 @@ "shape": "map", "rows": 2, "columns": [ + { + "key": "@key", + "name_ko": "출처 키", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "doc", "name_ko": "문서", @@ -1716,32 +1833,38 @@ { "key": "double_count_rules/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "not_found/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "pending_choices/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "原文_뒷받침/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -2183,57 +2306,68 @@ { "key": "composite/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "composite/unit_conversion", "name_ko": "단위 환산", - "summary": "단가 단위와 원단위 단위가 달라 맞추는 값." + "summary": "단가 단위와 원단위 단위가 달라 맞추는 값.", + "role": "value" }, { "key": "concrete_placing", "name_ko": "콘크리트 타설 잇기", - "summary": "타설 방식 × 구조물 종류로 공종이 갈리는 자리." + "summary": "타설 방식 × 구조물 종류로 공종이 갈리는 자리.", + "role": "value" }, { "key": "ground_aliases_moved_to", "name_ko": "갈래 별칭 옮긴 자리", - "summary": "갈래 이름 별칭은 별칭표로 옮겼다. 여기 다시 두지 않는다." + "summary": "갈래 이름 별칭은 별칭표로 옮겼다. 여기 다시 두지 않는다.", + "role": "note" }, { "key": "masonry_class_reference", "name_ko": "돌쌓기 갈래 참고 자리", - "summary": "돌쌓기 갈래는 이제 참고용이고 어긋나면 그것이 신호다." + "summary": "돌쌓기 갈래는 이제 참고용이고 어긋나면 그것이 신호다.", + "role": "note" }, { "key": "master", "name_ko": "마스터 자리", - "summary": "공종 코드를 읽어 오는 마스터 파일." + "summary": "공종 코드를 읽어 오는 마스터 파일.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "pending_user/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "pipe", "name_ko": "배수관 잇기", - "summary": "관종으로 공종이 갈린다. 관 정본은 pipe_points 다." + "summary": "관종으로 공종이 갈린다. 관 정본은 pipe_points 다.", + "role": "value" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "variant_contract", "name_ko": "갈래 키 계약", - "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속." + "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속.", + "role": "note" } ] }, @@ -2294,7 +2428,8 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -2355,12 +2490,14 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" } ] }, @@ -2454,17 +2591,20 @@ { "key": "variables/aliases", "name_ko": "노임 별칭", - "summary": "코드 대신 쓰는 별칭 이름표(labor_1002 = 보통인부)." + "summary": "코드 대신 쓰는 별칭 이름표(labor_1002 = 보통인부).", + "role": "value" }, { "key": "variables/labor_rate/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/labor_rate/reliability_note", "name_ko": "신뢰도 기호 뜻풀이", - "summary": "노임 표의 * · ** 가 무엇을 뜻하는지." + "summary": "노임 표의 * · ** 가 무엇을 뜻하는지.", + "role": "note" } ] }, @@ -2564,7 +2704,8 @@ { "key": "variables/labor_mfg/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" } ] }, @@ -2910,52 +3051,62 @@ { "key": "parse_audit", "name_ko": "읽기 점검", - "summary": "취득가 표에서 못 읽은 줄이 있는지 적어 둔 자리." + "summary": "취득가 표에서 못 읽은 줄이 있는지 적어 둔 자리.", + "role": "note" }, { "key": "variables/mach_fuel_rate/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/mach_fuel_rate/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "variables/mach_fuel_rate/unit", "name_ko": "값 단위", - "summary": "연료량은 L/시간이다." + "summary": "연료량은 L/시간이다.", + "role": "note" }, { "key": "variables/mach_loss_coef/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/mach_loss_coef/unit", "name_ko": "값 단위", - "summary": "손료계수는 1/시간이다." + "summary": "손료계수는 1/시간이다.", + "role": "note" }, { "key": "variables/mach_operator_map/note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "variables/mach_price/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" }, { "key": "variables/mach_price/unit", "name_ko": "값 단위", - "summary": "취득가는 천원 단위다." + "summary": "취득가는 천원 단위다.", + "role": "note" }, { "key": "variables/mach_rock_adj/unit", "name_ko": "값 단위", - "summary": "할증은 %다." + "summary": "할증은 %다.", + "role": "note" } ] }, @@ -3034,27 +3185,32 @@ { "key": "derived_from", "name_ko": "무엇에서 나왔나", - "summary": "어느 벌을 풀어 만든 파생본인지." + "summary": "어느 벌을 풀어 만든 파생본인지.", + "role": "note" }, { "key": "dropped_tables", "name_ko": "못 읽어 버린 표", - "summary": "칸 수가 안 맞아 못 읽고 버린 원문 표." + "summary": "칸 수가 안 맞아 못 읽고 버린 원문 표.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "stats", "name_ko": "집계", - "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", + "role": "note" } ] }, @@ -3211,17 +3367,20 @@ { "key": "selection_policy", "name_ko": "고르는 방침", - "summary": "같은 물품이 여러 번 공고되면 어느 것을 쓰는지." + "summary": "같은 물품이 여러 번 공고되면 어느 것을 쓰는지.", + "role": "note" }, { "key": "source_row_count", "name_ko": "원천 줄 수", - "summary": "걸러 내기 전 원본 줄 수." + "summary": "걸러 내기 전 원본 줄 수.", + "role": "note" }, { "key": "variables/mat_price/key", "name_ko": "줄을 가르는 칸", - "summary": "이 표에서 줄 하나를 집는 칸 이름." + "summary": "이 표에서 줄 하나를 집는 칸 이름.", + "role": "note" } ] }, @@ -3266,6 +3425,13 @@ "shape": "map", "rows": 2, "columns": [ + { + "key": "@key", + "name_ko": "유종", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "value", "name_ko": "단가", @@ -3400,57 +3566,68 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "variables/oil_diesel/date", "name_ko": "기준일", - "summary": "값이 선 날." + "summary": "값이 선 날.", + "role": "note" }, { "key": "variables/oil_diesel/scope", "name_ko": "걸치는 범위", - "summary": "어디까지 쓰는 값인지." + "summary": "어디까지 쓰는 값인지.", + "role": "note" }, { "key": "variables/oil_diesel/source_product_code", "name_ko": "원천 제품코드", - "summary": "원천이 쓰는 제품 코드." + "summary": "원천이 쓰는 제품 코드.", + "role": "note" }, { "key": "variables/oil_diesel/source_product_name", "name_ko": "원천 제품명", - "summary": "원천이 쓰는 제품 이름." + "summary": "원천이 쓰는 제품 이름.", + "role": "note" }, { "key": "variables/oil_diesel/unit", "name_ko": "값 단위", - "summary": "단가는 원/L 이다." + "summary": "단가는 원/L 이다.", + "role": "note" }, { "key": "variables/oil_gasoline/date", "name_ko": "기준일", - "summary": "값이 선 날." + "summary": "값이 선 날.", + "role": "note" }, { "key": "variables/oil_gasoline/scope", "name_ko": "걸치는 범위", - "summary": "어디까지 쓰는 값인지." + "summary": "어디까지 쓰는 값인지.", + "role": "note" }, { "key": "variables/oil_gasoline/source_product_code", "name_ko": "원천 제품코드", - "summary": "원천이 쓰는 제품 코드." + "summary": "원천이 쓰는 제품 코드.", + "role": "note" }, { "key": "variables/oil_gasoline/source_product_name", "name_ko": "원천 제품명", - "summary": "원천이 쓰는 제품 이름." + "summary": "원천이 쓰는 제품 이름.", + "role": "note" }, { "key": "variables/oil_gasoline/unit", "name_ko": "값 단위", - "summary": "단가는 원/L 이다." + "summary": "단가는 원/L 이다.", + "role": "note" } ] }, @@ -3495,6 +3672,13 @@ "shape": "map", "rows": 5, "columns": [ + { + "key": "@key", + "name_ko": "통화", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "value", "name_ko": "환율", @@ -3899,147 +4083,176 @@ { "key": "processing_rules", "name_ko": "셈 규칙", - "summary": "요율만으로는 금액이 안 나온다 — 밑수를 어떻게 만들고 어디서 자르는지 적은 자리. 코드와 어긋나면 거울 시험이 잡는다." + "summary": "요율만으로는 금액이 안 나온다 — 밑수를 어떻게 만들고 어디서 자르는지 적은 자리. 코드와 어긋나면 거울 시험이 잡는다.", + "role": "note" }, { "key": "variables/rate_asbestos_contribution", "name_ko": "석면피해구제 분담금", - "summary": "노무비에 곱하는 율." + "summary": "노무비에 곱하는 율.", + "role": "value" }, { "key": "variables/rate_care", "name_ko": "노인장기요양보험료", - "summary": "건강보험료 금액에 곱하는 율." + "summary": "건강보험료 금액에 곱하는 율.", + "role": "value" }, { "key": "variables/rate_environment/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_environment/forest_road_selection_status", "name_ko": "임도 줄 고름 상태", - "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지." + "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지.", + "role": "note" }, { "key": "variables/rate_environment/minimum_estimated_amount_krw", "name_ko": "적용 하한 — 추정금액", - "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + "summary": "이 금액에 못 미치면 줄이 안 선다(원).", + "role": "value" }, { "key": "variables/rate_equipment_payment_guarantee/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_equipment_payment_guarantee/base_note", "name_ko": "밑수 비고", - "summary": "밑수를 그렇게 적은 까닭." + "summary": "밑수를 그렇게 적은 까닭.", + "role": "note" }, { "key": "variables/rate_goyong/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_health", "name_ko": "건강보험료", - "summary": "직접노무비에 곱하는 율." + "summary": "직접노무비에 곱하는 율.", + "role": "value" }, { "key": "variables/rate_indirect_labor/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_other_expense/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_overhead/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_pension/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_pension/rate_from_2033_percent", "name_ko": "2033년 이후 요율", - "summary": "표 밖으로 나가는 해부터 쓰는 율(%)." + "summary": "표 밖으로 나가는 해부터 쓰는 율(%).", + "role": "value" }, { "key": "variables/rate_performance_guarantee_fee/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", "name_ko": "임도에 걸리는지", - "summary": "보통 임도 규모에서 이 항목이 서는지." + "summary": "보통 임도 규모에서 이 항목이 서는지.", + "role": "note" }, { "key": "variables/rate_profit/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_retirement_mutual_aid", "name_ko": "퇴직공제부금비", - "summary": "직접노무비에 곱하는 율. 추정금액 하한이 있다." + "summary": "직접노무비에 곱하는 율. 추정금액 하한이 있다.", + "role": "value" }, { "key": "variables/rate_safety_base", "name_ko": "산업안전보건관리비 기초액", - "summary": "요율표 안에 함께 든 기초액이 걸리는 구간." + "summary": "요율표 안에 함께 든 기초액이 걸리는 구간.", + "role": "value" }, { "key": "variables/rate_safety_pct/base_rule", "name_ko": "밑수 규칙", - "summary": "밑수를 고르는 방식." + "summary": "밑수를 고르는 방식.", + "role": "value" }, { "key": "variables/rate_safety_pct/base_with_owner_supplied_material", "name_ko": "대상액 — 관급자재 있을 때", - "summary": "고시가 준 두 식 가운데 작은 쪽." + "summary": "고시가 준 두 식 가운데 작은 쪽.", + "role": "value" }, { "key": "variables/rate_safety_pct/base_without_owner_supplied_material", "name_ko": "대상액 — 관급자재 없을 때", - "summary": "재료비 + 직접노무비." + "summary": "재료비 + 직접노무비.", + "role": "value" }, { "key": "variables/rate_safety_pct/manager_thresholds", "name_ko": "안전관리자 선임 기준액", - "summary": "안전관리자를 둬야 하는 금액 문턱." + "summary": "안전관리자를 둬야 하는 금액 문턱.", + "role": "value" }, { "key": "variables/rate_safety_pct/minimum_total_construction_amount_krw", "name_ko": "적용 하한 — 총공사금액", - "summary": "이 금액에 못 미치면 줄이 안 선다(원)." + "summary": "이 금액에 못 미치면 줄이 안 선다(원).", + "role": "value" }, { "key": "variables/rate_sanjae", "name_ko": "산재보험료", - "summary": "노무비에 곱하는 율." + "summary": "노무비에 곱하는 율.", + "role": "value" }, { "key": "variables/rate_subcontract_payment_guarantee/base", "name_ko": "밑수", - "summary": "이 요율을 곱하는 대상." + "summary": "이 요율을 곱하는 대상.", + "role": "value" }, { "key": "variables/rate_vat", "name_ko": "부가가치세", - "summary": "공급가액에 곱하는 율." + "summary": "공급가액에 곱하는 율.", + "role": "value" }, { "key": "variables/rate_wage_claim_contribution", "name_ko": "임금채권보장 부담금", - "summary": "노무비에 곱하는 율." + "summary": "노무비에 곱하는 율.", + "role": "value" } ] }, @@ -4088,7 +4301,8 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -4143,7 +4357,8 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -4246,12 +4461,14 @@ { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" }, { "key": "stats", "name_ko": "집계", - "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것." + "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", + "role": "note" } ] }, @@ -4300,7 +4517,8 @@ { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" } ] }, @@ -4393,6 +4611,13 @@ "shape": "map", "rows": 3, "columns": [ + { + "key": "@key", + "name_ko": "표 이름", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "note", "name_ko": "비고", @@ -4459,6 +4684,13 @@ "shape": "map", "rows": 15, "columns": [ + { + "key": "@key", + "name_ko": "칸 키", + "unit": "", + "visible": true, + "is_row_key": true + }, { "key": "label", "name_ko": "칸 이름", @@ -4514,42 +4746,50 @@ { "key": "code", "name_ko": "항목 코드", - "summary": "라이브러리 항목 코드." + "summary": "라이브러리 항목 코드.", + "role": "note" }, { "key": "differs_from_engine", "name_ko": "전개와 다른 자리", - "summary": "지금 돌아가는 전개와 이 양식이 갈리는 곳을 적어 둔 자리." + "summary": "지금 돌아가는 전개와 이 양식이 갈리는 곳을 적어 둔 자리.", + "role": "note" }, { "key": "item_kind", "name_ko": "항목 갈래", - "summary": "양식형(form)인지 고정형인지." + "summary": "양식형(form)인지 고정형인지.", + "role": "note" }, { "key": "library_tier", "name_ko": "라이브러리 단", - "summary": "프로그램·회사·개인 가운데 어느 단의 항목인지." + "summary": "프로그램·회사·개인 가운데 어느 단의 항목인지.", + "role": "note" }, { "key": "name", "name_ko": "항목 이름", - "summary": "화면에 보이는 이름." + "summary": "화면에 보이는 이름.", + "role": "note" }, { "key": "note", "name_ko": "설명", - "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글." + "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", + "role": "note" }, { "key": "type_id", "name_ko": "구조물 종류 키", - "summary": "어느 구조물 종류에 붙는 양식인지." + "summary": "어느 구조물 종류에 붙는 양식인지.", + "role": "note" }, { "key": "unit_price/note", "name_ko": "일위대가 틀 설명", - "summary": "일위대가 줄을 어떻게 세우는지 적은 글." + "summary": "일위대가 줄을 어떻게 세우는지 적은 글.", + "role": "note" } ] }, @@ -4691,7 +4931,8 @@ { "key": "policy", "name_ko": "방침", - "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것." + "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", + "role": "note" } ] }, @@ -4734,7 +4975,8 @@ { "key": "built_by", "name_ko": "만든 자리", - "summary": "이 벌을 지은 코드나 사람." + "summary": "이 벌을 지은 코드나 사람.", + "role": "note" } ] }, @@ -4777,12 +5019,14 @@ { "key": "built_by", "name_ko": "만든 자리", - "summary": "이 벌을 지은 코드나 사람." + "summary": "이 벌을 지은 코드나 사람.", + "role": "note" }, { "key": "source", "name_ko": "출처", - "summary": "어느 원문에서 왔는지." + "summary": "어느 원문에서 왔는지.", + "role": "note" } ] } diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 3e9ec49a..250a6672 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -95,9 +95,13 @@ def _scan(path: Path) -> tuple[list, list]: return tables, values +#: 사전 모양 표의 **줄 이름 열** — API 가 이 이름으로 내보낸다(2026-09-15 브레인 ①). +ROW_KEY_COLUMN = "@key" + + def _columns_of(node, shape: str) -> list[str]: records = node if shape == "list" else list(node.values()) - seen: list[str] = [] + seen: list[str] = [ROW_KEY_COLUMN] if shape == "map" else [] for record in records: if not isinstance(record, dict): continue @@ -281,3 +285,87 @@ def test_세어_둔_수가_실제와_같다(labels): def test_강우_IDF_캐시는_안_담았다(labels): 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"] From f2bfaf888d9d8fb902f7c561ab24d943bc79083b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:20:30 +0900 Subject: [PATCH 09/48] =?UTF-8?q?feat(z01):=20=EB=82=B1=EA=B0=92=20?= =?UTF-8?q?=ED=91=9C=20`@values`=20=E2=80=94=20=ED=91=9C=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EB=A7=88=EB=94=94(=EC=82=B0=EC=9E=AC=203.56%=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B0=92=20=EB=AC=B6=EC=9D=8C)=EB=A5=BC?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=A7=88=EB=8B=A4=20=ED=91=9C=20=ED=95=98?= =?UTF-8?q?=EB=82=98(=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=EC=98=81=EB=AC=B8=20=EC=9E=90=EB=A6=AC=20=EC=88=A8=EA=B9=80)?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=99=EC=9D=80=20rows=20=EA=B8=B8=EC=97=90=20?= =?UTF-8?q?=ED=83=9C=EC=9B=80=20=C2=B7=20=EC=84=A4=EB=AA=85=C2=B7=EB=B0=A9?= =?UTF-8?q?=EC=B9=A8(note=C2=B7policy=C2=B7source=20=E2=80=A6)=EC=9D=80=20?= =?UTF-8?q?=EB=BA=8C(=EA=B0=80=EB=A6=84=EC=9D=80=20=ED=95=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=98=A4=EB=A9=B4=20=EA=B0=88=EC=95=84=EB=81=BC?= =?UTF-8?q?=EC=9B=80)=20=C2=B7=20=EA=B0=92=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=C2=B7=EC=9D=B4=EB=A6=84=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=EC=99=80=20=EC=96=91=EC=AA=BD=20=EC=85=88=200(?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8=20=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_Tables.py | 65 ++++++++++++++++++++---- resources/tester/test_z01_master_data.py | 55 ++++++++++++++++++-- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py index 1d544f90..852cbb95 100644 --- a/Z01_MasterData/Z01_MasterData_Tables.py +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -6,6 +6,7 @@ 표 = **화면이 표로 그릴 마디** — 이름표 생성기와 같은 잣대(브레인 「91 이 정본」): · 딕셔너리 목록(비지 않음) → 줄 = 딕셔너리 그대로({열key: 값}) · 딕셔너리 둘 이상이 모인 딕셔너리(열이 비슷함) → 줄마다 `@key` 열(그 줄의 이름) + 값 딕셔너리 + · 표 아닌 마디(값 묶음 — 산재 3.56% 같은 낱값)는 파일마다 표 하나 `@values`(이름 · 값) — 설명·방침은 뺌(브레인 ④) ⚠ 표 id 가 이름표와 한 글자라도 다르면 이름이 통째로 안 붙음 — `test_z01_master_data.py` 가 양쪽으로 셈. """ @@ -22,6 +23,18 @@ LABEL_DIR = RESOURCES / "data_master_labels" DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 500 ROW_KEY = "@key" +VALUES_TABLE = "@values" +_CODE_HIDDEN = {"@path"} # 낱값 표의 영문 자리 — 이름표에 없을 때만 + +# ⚠ 잠정 — 이름표가 값 묶음마다 「값이냐 설명이냐」 표시를 주면 **이 자리만** 갈아끼움(브레인 ④) +_DESCRIPTION_KEYS = {"note", "notes", "policy", "source", "sources", "quote", "why"} + + +def is_description(group_key: str) -> bool: + """값 묶음이 설명·방침인가(값이 아님) — 끝 조각으로 가름.""" + last = group_key.rsplit("/", 1)[-1] + return last in _DESCRIPTION_KEYS or last.endswith("_note") + # 파일 머리 — 표도 값 묶음도 아님(이름표 생성기와 같은 목록) META_KEYS = { @@ -57,7 +70,9 @@ def _has_table(node: Any, depth: int = 0) -> bool: return any(_has_table(v, depth + 1) for v in node.values()) -def _collect(node: Any, path: list[str], out: dict[str, list], depth: int = 0) -> None: +def _collect( + node: Any, path: list[str], out: dict[str, list], values: dict, depth: int = 0 +) -> None: shape = _is_table(node) if shape == "list": out["/".join(path)] = node @@ -65,22 +80,45 @@ def _collect(node: Any, path: list[str], out: dict[str, list], depth: int = 0) - out["/".join(path)] = [{ROW_KEY: k, **v} for k, v in node.items()] elif depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()): for key, value in node.items(): - _collect(value, [*path, key], out, depth + 1) + _collect(value, [*path, key], out, values, depth + 1) + else: + values["/".join(path)] = node @lru_cache(maxsize=64) -def _tables_of(path: str, _mtime_ns: int) -> dict[str, list[dict[str, Any]]]: +def _scan(path: str, _mtime_ns: int) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]: doc = json.loads(Path(path).read_text(encoding="utf-8")) out: dict[str, list[dict[str, Any]]] = {} + values: dict[str, Any] = {} for key, value in doc.items(): if key not in META_KEYS: - _collect(value, [key], out) - return out + _collect(value, [key], out, values) + return out, values def tables_of(path: Path) -> dict[str, list[dict[str, Any]]]: """표 id(이름표의 표 key · `/` 로 이은 자리) → 줄.""" - return _tables_of(str(path), path.stat().st_mtime_ns) + return _scan(str(path), path.stat().st_mtime_ns)[0] + + +def values_of(path: Path) -> dict[str, Any]: + """값 묶음 자리(이름표의 값 묶음 key) → 값 — 설명·방침도 섞임.""" + return _scan(str(path), path.stat().st_mtime_ns)[1] + + +def _value_rows(fid: str, path: Path, labels: dict[str, Any]) -> list[dict[str, Any]]: + """낱값 표 줄 — 찾는 차례: 「파일id::key」 → 「끝 조각」 → 영문 key.""" + rows = [] + for key, value in values_of(path).items(): + if is_description(key): + continue + named = ( + (labels.get("value_overrides") or {}).get(f"{fid}::{key}") + or (labels.get("value_keys") or {}).get(key.rsplit("/", 1)[-1]) + or {} + ) + rows.append({"@name": named.get("name_ko") or key, "@value": value, "@path": key}) + return rows @lru_cache(maxsize=4) @@ -114,14 +152,18 @@ def tree() -> dict[str, Any]: named = {t["key"]: t.get("name_ko") or "" for t in entry.get("tables") or []} kind = entry.get("kind") or "" group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []}) + counts = {tid: len(rows) for tid, rows in tables_of(path).items()} + value_count = len(_value_rows(entry["file_id"], path, labels)) + if value_count: + counts[VALUES_TABLE] = value_count group["files"].append( { "id": entry["file_id"], "label": entry.get("name_ko") or entry["file_id"], "key": path.name, "tables": [ - {"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": len(rows)} - for tid, rows in tables_of(path).items() + {"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": n} + for tid, n in counts.items() ], } ) @@ -135,7 +177,10 @@ def rows( labels = load_labels() entry = next((f for f in labels["files"] if f["file_id"] == fid), None) path = _file_path(entry) if entry else None - table = tables_of(path).get(tid) if path else None + if path and tid == VALUES_TABLE: + table = _value_rows(fid, path, labels) or None + else: + table = tables_of(path).get(tid) if path else None if table is None: return None keys: dict[str, None] = {} @@ -157,7 +202,7 @@ def rows( "key": key, "label": named.get("name_ko") or key, "unit": named.get("unit") or "", - "hidden": named.get("visible") is False, + "hidden": named.get("visible") is False if named else key in _CODE_HIDDEN, } ) return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)} diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index 8be5f053..7b5d77ef 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -63,19 +63,22 @@ def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: Te def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: TestClient) -> None: - files = _files(client) + files = { # 낱값 표 `@values` 는 예약어 — 이름표 표 목록 밖(아래 시험이 따로 셈) + fid: [t for t in f["tables"] if t["id"] != tables.VALUES_TABLE] + for fid, f in _files(client).items() + } not_labelled, label_only = [], [] for entry in LABELS["files"]: - tree_ids = {t["id"] for t in files[entry["file_id"]]["tables"]} + tree_ids = {t["id"] for t in files[entry["file_id"]]} label_ids = {t["key"] for t in entry["tables"]} not_labelled += [f"{entry['file_id']}::{i}" for i in sorted(tree_ids - label_ids)] label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)] assert not_labelled == [], not_labelled assert label_only == [], label_only - assert sum(len(f["tables"]) for f in files.values()) == LABELS["counts"]["tables"] == 91 + assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 91 for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음 named = {t["key"]: t for t in entry["tables"]} - for t in files[entry["file_id"]]["tables"]: + for t in files[entry["file_id"]]: assert t["label"] == named[t["id"]]["name_ko"], (entry["file_id"], t["id"]) assert t["row_count"] == named[t["id"]]["rows"], (entry["file_id"], t["id"]) @@ -164,6 +167,50 @@ def test_찾는_차례와_없는_이름은_영문_key( ) +def test_낱값은_파일마다_표_하나_설명류는_뺌(client: TestClient) -> None: + """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 설명·방침은 값이 아님.""" + files = _files(client) + rates = files["rates"]["tables"] + assert rates[-1]["id"] == "@values" + values = _rows(client, file="rates", table="@values", size=500) + assert values["total"] == rates[-1]["row_count"] + by_path = {r["@path"]: r for r in values["rows"]} + sanjae = by_path["variables/rate_sanjae"] + assert sanjae["@value"] == {"rate_percent": 3.56, "base": "total_labor_cost"} + assert sanjae["@name"] == "산재보험료" + assert {c["key"]: c["hidden"] for c in values["columns"]} == { + "@name": False, + "@value": False, + "@path": True, + } + master = {r["@path"] for r in _rows(client, file="work_item_master", table="@values")["rows"]} + assert master == {"stats"} # policy 는 방침 + assert "@values" not in {t["id"] for t in files["aliases"]["tables"]} # note 뿐 + assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 + for f in files.values(): + if "@values" in {t["id"] for t in f["tables"]}: + paths = [ + r["@path"] for r in _rows(client, file=f["id"], table="@values", size=500)["rows"] + ] + assert not [p for p in paths if tables.is_description(p)], f["id"] + hit = _rows(client, file="rates", table="@values", q="산재") + assert [r["@path"] for r in hit["rows"]] == ["variables/rate_sanjae"] + + +def test_값_묶음_자리는_이름표와_양쪽으로_같음() -> None: + not_labelled, label_only = [], [] + for entry in LABELS["files"]: + ours = set(tables.values_of(tables.ROOT / entry["path"])) + theirs = {g["key"] for g in entry["value_groups"]} + not_labelled += [f"{entry['file_id']}::{k}" for k in sorted(ours - theirs)] + label_only += [f"{entry['file_id']}::{k}" for k in sorted(theirs - ours)] + assert not_labelled == [] and label_only == [], (not_labelled, label_only) + for entry in LABELS["files"]: # 이름도 이름표가 푼 그대로(찾는 차례 「파일id::key」 → 끝 조각) + named = {g["key"]: g["name_ko"] for g in entry["value_groups"]} + rows = tables._value_rows(entry["file_id"], tables.ROOT / entry["path"], LABELS) + assert [r["@name"] for r in rows] == [named[r["@path"]] for r in rows], entry["file_id"] + + def test_없는_파일_표는_404(client: TestClient) -> None: for params in ( {"file": "nope", "table": "rows"}, From 8fb1952ba9455c9470032d3cc5bb3f3777be17b4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:24:01 +0900 Subject: [PATCH 10/48] =?UTF-8?q?feat(z01):=20=EB=82=B1=EA=B0=92=20?= =?UTF-8?q?=EB=A7=88=EB=94=94=20=EC=9D=B4=EB=A6=84=20=EB=84=B7=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EA=B0=92=C2=B7=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=A5=BC=20`kind`=20=EB=82=B1=EB=A7=90?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브레인 확정 이름 그대로 — `synthetic_tables["@values"]` 표 이름 「낱값」 · 설명 「표가 아닌 한 값들」 · 열 `@name` 「항목」 · `@value` 「값」 · `@path` 「원문 자리」(숨김) - 값 묶음 표시를 참·거짓이 아니라 **낱말** `kind: "value" | "doc"` 로 둠 — 수식 같은 갈래가 늘면 낱말만 더하면 되고 뜻이 안 뒤집힘 · 갈래(kinds) 말투와도 맞음 - 이름 갈이 — `role` → `kind` · `value_roles` → `value_kinds` · `counts.value_groups_by_kind` · `policy.value_kind_rule` - 셈은 그대로(값 49 · 설명 123) · 시험 26건(2건 늘, 낱값 마디 이름 넷과 낱말 여부를 잡음) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 383 ++++++++++-------- resources/tester/test_master_labels_cover.py | 34 +- 2 files changed, 229 insertions(+), 188 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index ae9fbe4b..e7a836e7 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T19:16:20+09:00", + "generated_at": "2026-09-15T19:23:25+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -13,7 +13,7 @@ "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", "row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).", - "value_role_rule": "값 묶음은 갈래가 둘이다. **값** = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. **설명** = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·표 머리의 키·단위·기준일) — 낱값 마디에 **안 보인다**. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명(2026-09-15 브레인 ②)." + "value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·표 머리의 키·단위·기준일) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명(2026-09-15 브레인 ②)." }, "lookup_order": { "column": [ @@ -49,24 +49,51 @@ "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." } }, - "value_roles": { + "value_kinds": { "value": { "name_ko": "값", "summary": "원가·수량에 그대로 드는 값. 낱값 마디에 **보인다** — 여기서 사라지면 트리에서 값이 사라진다." }, - "note": { + "doc": { "name_ko": "설명", "summary": "읽으라고 적어 둔 글(방침·출처·비고·기록). 낱값 마디에 **안 보인다** — 보이면 화면이 흐려진다." } }, + "synthetic_tables": { + "@values": { + "name_ko": "낱값", + "summary": "표가 아닌 한 값들", + "columns": [ + { + "key": "@name", + "name_ko": "항목", + "unit": "", + "visible": true + }, + { + "key": "@value", + "name_ko": "값", + "unit": "", + "visible": true + }, + { + "key": "@path", + "name_ko": "원문 자리", + "unit": "", + "visible": false + } + ], + "note": "`kind: \"value\"` 인 값 묶음만 여기 선다. `kind: \"doc\"` 은 안 보인다." + } + }, "counts": { "files": 34, "tables": 91, "columns": 455, "value_groups": 172, "unknown": 0, - "value_groups_by_role": { - "note": 123, + "value_groups_by_kind": { + "doc": 123, "value": 49 } }, @@ -150,13 +177,13 @@ "key": "variables/pum/normalization_status", "name_ko": "정돈 상태", "summary": "자원 코드 정돈이 끝났는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/pum/representation", "name_ko": "담은 꼴", "summary": "원문 표를 어떤 꼴로 담았는지.", - "role": "note" + "kind": "doc" } ] }, @@ -245,13 +272,13 @@ "key": "variables/pum/normalization_status", "name_ko": "정돈 상태", "summary": "자원 코드 정돈이 끝났는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/pum/representation", "name_ko": "담은 꼴", "summary": "원문 표를 어떤 꼴로 담았는지.", - "role": "note" + "kind": "doc" } ] }, @@ -364,13 +391,13 @@ "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "stats", "name_ko": "집계", "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", - "role": "note" + "kind": "doc" } ] }, @@ -576,67 +603,67 @@ "key": "variables/coef_soil_C/duplicate_note", "name_ko": "원문 중복 비고", "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리.", - "role": "note" + "kind": "doc" }, { "key": "variables/coef_soil_C/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/coef_soil_L/duplicate_note", "name_ko": "원문 중복 비고", "summary": "원문이 같은 줄을 두 번 실어 어느 줄을 쓸지 남은 자리.", - "role": "note" + "kind": "doc" }, { "key": "variables/coef_soil_L/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/rate_tool", "name_ko": "공구손료율", "summary": "주요 재료비에 곱하는 공구손료 율(하한·상한). 어느 값을 쓸지는 고름이 필요하다.", - "role": "value" + "kind": "value" }, { "key": "variables/surcharge_labor/application_status", "name_ko": "적용 상태", "summary": "쓰려면 무엇이 더 정해져야 하는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/surcharge_labor/representation", "name_ko": "담은 꼴", "summary": "원문 표를 어떤 꼴로 담았는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/surcharge_labor/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "role": "note" + "kind": "doc" }, { "key": "variables/surcharge_mat/duplicate_application_prohibited", "name_ko": "중복 적용 금지", "summary": "두 자리에 걸면 두 번 세게 된다는 표시.", - "role": "note" + "kind": "doc" }, { "key": "variables/surcharge_mat/representation", "name_ko": "담은 꼴", "summary": "원문 표를 어떤 꼴로 담았는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/surcharge_mat/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "role": "note" + "kind": "doc" } ] }, @@ -783,25 +810,25 @@ "key": "candidates_pending_user/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "not_found", "name_ko": "못 찾은 것", "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것.", - "role": "note" + "kind": "doc" }, { "key": "observed_practice", "name_ko": "실무 관측", "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다.", - "role": "value" + "kind": "value" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" } ] }, @@ -944,55 +971,55 @@ "key": "euroform_type/material_unit_note", "name_ko": "자재 밑수 비고", "summary": "자재 환산을 어느 창이 하는지.", - "role": "note" + "kind": "doc" }, { "key": "euroform_type/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "euroform_type/reuse_note", "name_ko": "사용횟수 비고", "summary": "닮은 두 사용횟수를 하나로 잇지 않는 까닭.", - "role": "note" + "kind": "doc" }, { "key": "euroform_type/source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "reuse_ratio_pct", "name_ko": "사용횟수별 기준수량 비율", "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다.", - "role": "value" + "kind": "value" }, { "key": "shoring", "name_ko": "동바리", "summary": "강관동바리가 어느 구조물에 서는지.", - "role": "value" + "kind": "value" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" } ] }, @@ -1081,25 +1108,25 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "price_hint_krw_per_ton", "name_ko": "단가 참고값", "summary": "표시 전용 — 갈래 차이를 사람이 보라고 둔 값. 계산에 안 들어간다.", - "role": "value" + "kind": "value" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" } ] }, @@ -1194,25 +1221,25 @@ "key": "basis_unit", "name_ko": "밑수", "summary": "값이 무엇 하나당인지.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "pending_user", "name_ko": "사용자 확정 대기", "summary": "아직 사람이 답해야 닫히는 물음.", - "role": "note" + "kind": "doc" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" } ] }, @@ -1250,43 +1277,43 @@ "key": "back_length/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "back_length/option_key", "name_ko": "고르개 키", "summary": "화면 고르개가 쓰는 저장 칸 이름.", - "role": "value" + "kind": "value" }, { "key": "back_length/source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" }, { "key": "bond", "name_ko": "쌓기 방식", "summary": "메쌓기·찰쌓기로 공종코드가 갈린다.", - "role": "value" + "kind": "value" }, { "key": "boulder_diameter", "name_ko": "돌 직경 갈래", "summary": "큰돌쌓기 — 저장 제원 stone_cm 으로 갈린다.", - "role": "value" + "kind": "value" }, { "key": "face_slope", "name_ko": "전면 기울기", "summary": "형식마다 정해진 1:n. 코드 기본 0.3 이 여기서 왔다.", - "role": "value" + "kind": "value" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -1331,31 +1358,31 @@ "key": "not_here", "name_ko": "이 표가 안 다루는 것", "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" }, { "key": "steps_m", "name_ko": "직고 칸", "summary": "표가 갈리는 높이 경계(m).", - "role": "value" + "kind": "value" } ] }, @@ -1371,37 +1398,37 @@ "key": "not_here", "name_ko": "이 표가 안 다루는 것", "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" }, { "key": "steps_m", "name_ko": "직고 칸", "summary": "표가 갈리는 높이 경계(m).", - "role": "value" + "kind": "value" }, { "key": "table_cm", "name_ko": "뒷길이 범위표", "summary": "메·찰마다 직고 칸별 뒷길이 하한·상한(㎝).", - "role": "value" + "kind": "value" } ] }, @@ -1452,79 +1479,79 @@ "key": "back_lengths_cm", "name_ko": "뒷길이 규격", "summary": "표가 값을 주는 뒷길이 일곱 칸.", - "role": "value" + "kind": "value" }, { "key": "backfill_ratio_of_back_length", "name_ko": "뒤채움 몫", "summary": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이다.", - "role": "value" + "kind": "value" }, { "key": "fallback", "name_ko": "미지정일 때", "summary": "사용자가 안 고른 경우 쓰는 한 벌.", - "role": "value" + "kind": "value" }, { "key": "fill_concrete_m3_per_m2", "name_ko": "채움 콘크리트 원단위", "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. 원문이 두 줄뿐이다.", - "role": "value" + "kind": "value" }, { "key": "kinds", "name_ko": "돌 종류", "summary": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌 네 줄.", - "role": "value" + "kind": "value" }, { "key": "no_folding", "name_ko": "접지 않음 규칙", "summary": "표에 없는 뒷길이를 가까운 칸으로 접지 않는다 — 접으면 값이 조용히 틀린다.", - "role": "note" + "kind": "doc" }, { "key": "not_here", "name_ko": "이 표가 안 다루는 것", "summary": "헷갈리기 쉬운 이웃 값을 여기 안 둔다고 적어 둔 자리.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "option_key", "name_ko": "고르개 키", "summary": "화면 고르개가 쓰는 저장 칸 이름.", - "role": "value" + "kind": "value" }, { "key": "option_note", "name_ko": "고르개 비고", "summary": "고르개를 만들 때 챙길 것.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "wedge_stone_m3_per_m2", "name_ko": "고임돌 원단위", "summary": "돌 종류 × 뒷길이마다 ㎥/㎡. null 은 원문 「-」다.", - "role": "value" + "kind": "value" }, { "key": "why", "name_ko": "까닭", "summary": "이 벌을 왜 따로 두는지.", - "role": "note" + "kind": "doc" } ] }, @@ -1540,25 +1567,25 @@ "key": "items", "name_ko": "항목", "summary": "이 벌이 담는 항목 묶음.", - "role": "value" + "kind": "value" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "rank", "name_ko": "우선순위", "summary": "원문끼리 어긋날 때 어느 쪽을 먼저 보는지.", - "role": "note" + "kind": "doc" }, { "key": "scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "role": "note" + "kind": "doc" } ] }, @@ -1834,37 +1861,37 @@ "key": "double_count_rules/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "not_found/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "pending_choices/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "原文_뒷받침/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -2307,67 +2334,67 @@ "key": "composite/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "composite/unit_conversion", "name_ko": "단위 환산", "summary": "단가 단위와 원단위 단위가 달라 맞추는 값.", - "role": "value" + "kind": "value" }, { "key": "concrete_placing", "name_ko": "콘크리트 타설 잇기", "summary": "타설 방식 × 구조물 종류로 공종이 갈리는 자리.", - "role": "value" + "kind": "value" }, { "key": "ground_aliases_moved_to", "name_ko": "갈래 별칭 옮긴 자리", "summary": "갈래 이름 별칭은 별칭표로 옮겼다. 여기 다시 두지 않는다.", - "role": "note" + "kind": "doc" }, { "key": "masonry_class_reference", "name_ko": "돌쌓기 갈래 참고 자리", "summary": "돌쌓기 갈래는 이제 참고용이고 어긋나면 그것이 신호다.", - "role": "note" + "kind": "doc" }, { "key": "master", "name_ko": "마스터 자리", "summary": "공종 코드를 읽어 오는 마스터 파일.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "pending_user/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "pipe", "name_ko": "배수관 잇기", "summary": "관종으로 공종이 갈린다. 관 정본은 pipe_points 다.", - "role": "value" + "kind": "value" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "variant_contract", "name_ko": "갈래 키 계약", "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속.", - "role": "note" + "kind": "doc" } ] }, @@ -2429,7 +2456,7 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -2491,13 +2518,13 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" } ] }, @@ -2592,19 +2619,19 @@ "key": "variables/aliases", "name_ko": "노임 별칭", "summary": "코드 대신 쓰는 별칭 이름표(labor_1002 = 보통인부).", - "role": "value" + "kind": "value" }, { "key": "variables/labor_rate/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/labor_rate/reliability_note", "name_ko": "신뢰도 기호 뜻풀이", "summary": "노임 표의 * · ** 가 무엇을 뜻하는지.", - "role": "note" + "kind": "doc" } ] }, @@ -2705,7 +2732,7 @@ "key": "variables/labor_mfg/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" } ] }, @@ -3052,61 +3079,61 @@ "key": "parse_audit", "name_ko": "읽기 점검", "summary": "취득가 표에서 못 읽은 줄이 있는지 적어 둔 자리.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_fuel_rate/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_fuel_rate/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_fuel_rate/unit", "name_ko": "값 단위", "summary": "연료량은 L/시간이다.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_loss_coef/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_loss_coef/unit", "name_ko": "값 단위", "summary": "손료계수는 1/시간이다.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_operator_map/note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_price/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_price/unit", "name_ko": "값 단위", "summary": "취득가는 천원 단위다.", - "role": "note" + "kind": "doc" }, { "key": "variables/mach_rock_adj/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "role": "note" + "kind": "doc" } ] }, @@ -3186,31 +3213,31 @@ "key": "derived_from", "name_ko": "무엇에서 나왔나", "summary": "어느 벌을 풀어 만든 파생본인지.", - "role": "note" + "kind": "doc" }, { "key": "dropped_tables", "name_ko": "못 읽어 버린 표", "summary": "칸 수가 안 맞아 못 읽고 버린 원문 표.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "stats", "name_ko": "집계", "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", - "role": "note" + "kind": "doc" } ] }, @@ -3368,19 +3395,19 @@ "key": "selection_policy", "name_ko": "고르는 방침", "summary": "같은 물품이 여러 번 공고되면 어느 것을 쓰는지.", - "role": "note" + "kind": "doc" }, { "key": "source_row_count", "name_ko": "원천 줄 수", "summary": "걸러 내기 전 원본 줄 수.", - "role": "note" + "kind": "doc" }, { "key": "variables/mat_price/key", "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", - "role": "note" + "kind": "doc" } ] }, @@ -3567,67 +3594,67 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_diesel/date", "name_ko": "기준일", "summary": "값이 선 날.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_diesel/scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_diesel/source_product_code", "name_ko": "원천 제품코드", "summary": "원천이 쓰는 제품 코드.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_diesel/source_product_name", "name_ko": "원천 제품명", "summary": "원천이 쓰는 제품 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_diesel/unit", "name_ko": "값 단위", "summary": "단가는 원/L 이다.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_gasoline/date", "name_ko": "기준일", "summary": "값이 선 날.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_gasoline/scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_gasoline/source_product_code", "name_ko": "원천 제품코드", "summary": "원천이 쓰는 제품 코드.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_gasoline/source_product_name", "name_ko": "원천 제품명", "summary": "원천이 쓰는 제품 이름.", - "role": "note" + "kind": "doc" }, { "key": "variables/oil_gasoline/unit", "name_ko": "값 단위", "summary": "단가는 원/L 이다.", - "role": "note" + "kind": "doc" } ] }, @@ -4084,175 +4111,175 @@ "key": "processing_rules", "name_ko": "셈 규칙", "summary": "요율만으로는 금액이 안 나온다 — 밑수를 어떻게 만들고 어디서 자르는지 적은 자리. 코드와 어긋나면 거울 시험이 잡는다.", - "role": "note" + "kind": "doc" }, { "key": "variables/rate_asbestos_contribution", "name_ko": "석면피해구제 분담금", "summary": "노무비에 곱하는 율.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_care", "name_ko": "노인장기요양보험료", "summary": "건강보험료 금액에 곱하는 율.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_environment/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_environment/forest_road_selection_status", "name_ko": "임도 줄 고름 상태", "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/rate_environment/minimum_estimated_amount_krw", "name_ko": "적용 하한 — 추정금액", "summary": "이 금액에 못 미치면 줄이 안 선다(원).", - "role": "value" + "kind": "value" }, { "key": "variables/rate_equipment_payment_guarantee/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_equipment_payment_guarantee/base_note", "name_ko": "밑수 비고", "summary": "밑수를 그렇게 적은 까닭.", - "role": "note" + "kind": "doc" }, { "key": "variables/rate_goyong/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_health", "name_ko": "건강보험료", "summary": "직접노무비에 곱하는 율.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_indirect_labor/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_other_expense/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_overhead/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_pension/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_pension/rate_from_2033_percent", "name_ko": "2033년 이후 요율", "summary": "표 밖으로 나가는 해부터 쓰는 율(%).", - "role": "value" + "kind": "value" }, { "key": "variables/rate_performance_guarantee_fee/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", "name_ko": "임도에 걸리는지", "summary": "보통 임도 규모에서 이 항목이 서는지.", - "role": "note" + "kind": "doc" }, { "key": "variables/rate_profit/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_retirement_mutual_aid", "name_ko": "퇴직공제부금비", "summary": "직접노무비에 곱하는 율. 추정금액 하한이 있다.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_base", "name_ko": "산업안전보건관리비 기초액", "summary": "요율표 안에 함께 든 기초액이 걸리는 구간.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_pct/base_rule", "name_ko": "밑수 규칙", "summary": "밑수를 고르는 방식.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_pct/base_with_owner_supplied_material", "name_ko": "대상액 — 관급자재 있을 때", "summary": "고시가 준 두 식 가운데 작은 쪽.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_pct/base_without_owner_supplied_material", "name_ko": "대상액 — 관급자재 없을 때", "summary": "재료비 + 직접노무비.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_pct/manager_thresholds", "name_ko": "안전관리자 선임 기준액", "summary": "안전관리자를 둬야 하는 금액 문턱.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_safety_pct/minimum_total_construction_amount_krw", "name_ko": "적용 하한 — 총공사금액", "summary": "이 금액에 못 미치면 줄이 안 선다(원).", - "role": "value" + "kind": "value" }, { "key": "variables/rate_sanjae", "name_ko": "산재보험료", "summary": "노무비에 곱하는 율.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_subcontract_payment_guarantee/base", "name_ko": "밑수", "summary": "이 요율을 곱하는 대상.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_vat", "name_ko": "부가가치세", "summary": "공급가액에 곱하는 율.", - "role": "value" + "kind": "value" }, { "key": "variables/rate_wage_claim_contribution", "name_ko": "임금채권보장 부담금", "summary": "노무비에 곱하는 율.", - "role": "value" + "kind": "value" } ] }, @@ -4302,7 +4329,7 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -4358,7 +4385,7 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -4462,13 +4489,13 @@ "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" }, { "key": "stats", "name_ko": "집계", "summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.", - "role": "note" + "kind": "doc" } ] }, @@ -4518,7 +4545,7 @@ "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -4747,49 +4774,49 @@ "key": "code", "name_ko": "항목 코드", "summary": "라이브러리 항목 코드.", - "role": "note" + "kind": "doc" }, { "key": "differs_from_engine", "name_ko": "전개와 다른 자리", "summary": "지금 돌아가는 전개와 이 양식이 갈리는 곳을 적어 둔 자리.", - "role": "note" + "kind": "doc" }, { "key": "item_kind", "name_ko": "항목 갈래", "summary": "양식형(form)인지 고정형인지.", - "role": "note" + "kind": "doc" }, { "key": "library_tier", "name_ko": "라이브러리 단", "summary": "프로그램·회사·개인 가운데 어느 단의 항목인지.", - "role": "note" + "kind": "doc" }, { "key": "name", "name_ko": "항목 이름", "summary": "화면에 보이는 이름.", - "role": "note" + "kind": "doc" }, { "key": "note", "name_ko": "설명", "summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.", - "role": "note" + "kind": "doc" }, { "key": "type_id", "name_ko": "구조물 종류 키", "summary": "어느 구조물 종류에 붙는 양식인지.", - "role": "note" + "kind": "doc" }, { "key": "unit_price/note", "name_ko": "일위대가 틀 설명", "summary": "일위대가 줄을 어떻게 세우는지 적은 글.", - "role": "note" + "kind": "doc" } ] }, @@ -4932,7 +4959,7 @@ "key": "policy", "name_ko": "방침", "summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.", - "role": "note" + "kind": "doc" } ] }, @@ -4976,7 +5003,7 @@ "key": "built_by", "name_ko": "만든 자리", "summary": "이 벌을 지은 코드나 사람.", - "role": "note" + "kind": "doc" } ] }, @@ -5020,13 +5047,13 @@ "key": "built_by", "name_ko": "만든 자리", "summary": "이 벌을 지은 코드나 사람.", - "role": "note" + "kind": "doc" }, { "key": "source", "name_ko": "출처", "summary": "어느 원문에서 왔는지.", - "role": "note" + "kind": "doc" } ] } diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 250a6672..d13ed374 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -330,26 +330,40 @@ def test_줄_이름_열의_이름이_표마다_제_것이다(labels): assert duplicated <= {"출처 키"}, f"줄 이름이 겹친다: {sorted(duplicated)}" -def test_값_묶음마다_값이냐_설명이냐가_적혀_있다(labels): - known = set(labels["value_roles"]) +def test_값_묶음마다_kind_가_낱말로_적혀_있다(labels): + known = set(labels["value_kinds"]) bad = [] for entry in labels["files"]: for group in entry["value_groups"]: - if group.get("role") not in known: + if group.get("kind") 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"] +def test_값_갈래_잣대와_낱값_마디가_적혀_있다(labels): + rule = labels["policy"]["value_kind_rule"] assert "금액이 움직이나" in rule assert labels["policy"]["row_key_column"] + node = labels["synthetic_tables"]["@values"] + assert node["name_ko"] == "낱값" + assert node["summary"] == "표가 아닌 한 값들" + assert [c["key"] for c in node["columns"]] == ["@name", "@value", "@path"] + assert [c["name_ko"] for c in node["columns"]] == ["항목", "값", "원문 자리"] + assert node["columns"][2]["visible"] is False + + +def test_값_갈래는_참거짓이_아니라_낱말이다(labels): + """수식 같은 갈래가 늘면 낱말만 더하면 된다 — 참·거짓이면 그때 뜻이 뒤집힌다.""" + for entry in labels["files"]: + for group in entry["value_groups"]: + assert isinstance(group["kind"], str), f"{entry['file_id']}::{group['key']}" + assert set(labels["value_kinds"]) == {"value", "doc"} 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"]} + by_key = {g["key"]: g["kind"] for g in rates["value_groups"]} for key in ( "variables/rate_sanjae", "variables/rate_health", @@ -360,12 +374,12 @@ def test_한_값짜리_요율은_값으로_선다(labels): "variables/rate_asbestos_contribution", ): assert by_key.get(key) == "value", f"{key} 가 값으로 안 섰다" - assert by_key.get("processing_rules") == "note" + assert by_key.get("processing_rules") == "doc" -def test_세어_둔_갈래_수가_실제와_같다(labels): +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"] + counted[group["kind"]] = counted.get(group["kind"], 0) + 1 + assert counted == labels["counts"]["value_groups_by_kind"] From df2b62a34eeb588f3c059f076e6b572905ee72a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:27:49 +0900 Subject: [PATCH 11/48] =?UTF-8?q?feat(z01):=20=EB=82=B1=EA=B0=92=20?= =?UTF-8?q?=EA=B0=92=C2=B7=EC=84=A4=EB=AA=85=20=EA=B0=80=EB=A6=84=EC=9D=84?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=ED=91=9C=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=88=EC=95=84=EB=81=BC=EC=9B=80=20=E2=80=94=20=EC=9E=A0?= =?UTF-8?q?=EC=A0=95=20=EC=9E=A3=EB=8C=80=20=EB=BA=8C=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=20`role`=20value=20=EB=8A=94=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=84=20=C2=B7=20note=20=EB=8A=94=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=97=86=EA=B1=B0=EB=82=98=20=EB=AA=A8?= =?UTF-8?q?=EB=A5=B4=EB=8A=94=20=EB=82=B1=EB=A7=90=EC=9D=80=20=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9D=B4=EB=90=98=20=E3=80=8C?= =?UTF-8?q?=EB=AF=B8=ED=8C=90=EC=A0=95=E3=80=8D=20=EC=B9=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=93=9C=EB=9F=AC=EB=83=84(=EC=A7=84=EC=A7=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=ED=91=9C=200=20=EC=9D=84=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=EC=9C=BC=EB=A1=9C=20=EB=AA=BB=EB=B0=95=EC=9D=8C)=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EC=9D=B4=EB=A6=84=20=EC=97=B4=20`@key`=20?= =?UTF-8?q?=EB=8A=94=20=EA=B7=B8=20=ED=91=9C=20=EC=97=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=EC=A0=9C=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EC=B0=BE=EC=9D=8C(fx=20=ED=86=B5=ED=99=94?= =?UTF-8?q?=20=C2=B7=20masonry=5Fslope=20=EC=8C=93=EA=B8=B0=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_Tables.py | 44 ++++++++++------ resources/tester/test_z01_master_data.py | 65 +++++++++++++++++++----- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py index 852cbb95..55b94e2e 100644 --- a/Z01_MasterData/Z01_MasterData_Tables.py +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -26,14 +26,13 @@ ROW_KEY = "@key" VALUES_TABLE = "@values" _CODE_HIDDEN = {"@path"} # 낱값 표의 영문 자리 — 이름표에 없을 때만 -# ⚠ 잠정 — 이름표가 값 묶음마다 「값이냐 설명이냐」 표시를 주면 **이 자리만** 갈아끼움(브레인 ④) -_DESCRIPTION_KEYS = {"note", "notes", "policy", "source", "sources", "quote", "why"} - - -def is_description(group_key: str) -> bool: - """값 묶음이 설명·방침인가(값이 아님) — 끝 조각으로 가름.""" - last = group_key.rsplit("/", 1)[-1] - return last in _DESCRIPTION_KEYS or last.endswith("_note") +# 값 묶음 갈래 — 이름표 `value_groups[].role`(데스크탑 서브) · 낱말이 바뀌면 이 자리만 갈아끼움 +ROLE_FIELD = "role" +SHOWN_ROLE = "value" # 원가·수량에 드는 값 — 낱값 표에 보임 +HIDDEN_ROLE = "note" # 읽으라고 적은 글 — 안 보임 +# 이름표가 안 가른(또는 모르는 낱말) 마디 — 조용히 빼지도 섞지도 않고 값으로 보이되 드러냄(브레인) +UNDECIDED_COLUMN = "@undecided" +UNDECIDED = "미판정" # 파일 머리 — 표도 값 묶음도 아님(이름표 생성기와 같은 목록) @@ -106,18 +105,24 @@ def values_of(path: Path) -> dict[str, Any]: return _scan(str(path), path.stat().st_mtime_ns)[1] -def _value_rows(fid: str, path: Path, labels: dict[str, Any]) -> list[dict[str, Any]]: - """낱값 표 줄 — 찾는 차례: 「파일id::key」 → 「끝 조각」 → 영문 key.""" +def _value_rows(entry: dict[str, Any], path: Path, labels: dict[str, Any]) -> list[dict[str, Any]]: + """낱값 표 줄 — 이름 찾는 차례: 「파일id::key」 → 「끝 조각」 → 영문 key.""" + fid = entry["file_id"] + roles = {g.get("key"): g.get(ROLE_FIELD) for g in entry.get("value_groups") or []} rows = [] for key, value in values_of(path).items(): - if is_description(key): + role = roles.get(key) + if role == HIDDEN_ROLE: continue named = ( (labels.get("value_overrides") or {}).get(f"{fid}::{key}") or (labels.get("value_keys") or {}).get(key.rsplit("/", 1)[-1]) or {} ) - rows.append({"@name": named.get("name_ko") or key, "@value": value, "@path": key}) + row = {"@name": named.get("name_ko") or key, "@value": value, "@path": key} + if role != SHOWN_ROLE: + row[UNDECIDED_COLUMN] = UNDECIDED + rows.append(row) return rows @@ -153,7 +158,7 @@ def tree() -> dict[str, Any]: kind = entry.get("kind") or "" group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []}) counts = {tid: len(rows) for tid, rows in tables_of(path).items()} - value_count = len(_value_rows(entry["file_id"], path, labels)) + value_count = len(_value_rows(entry, path, labels)) if value_count: counts[VALUES_TABLE] = value_count group["files"].append( @@ -178,11 +183,13 @@ def rows( entry = next((f for f in labels["files"] if f["file_id"] == fid), None) path = _file_path(entry) if entry else None if path and tid == VALUES_TABLE: - table = _value_rows(fid, path, labels) or None + table = _value_rows(entry, path, labels) or None else: table = tables_of(path).get(tid) if path else None if table is None: return None + own = next((t for t in entry.get("tables") or [] if t.get("key") == tid), {}) + own_columns = {c.get("key"): c for c in own.get("columns") or []} keys: dict[str, None] = {} for r in table: keys.update(dict.fromkeys(r)) @@ -195,8 +202,13 @@ def rows( size = max(1, min(size, MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size columns = [] - for key in keys: # 찾는 차례: 「파일id/열key」 → 「열key」 → 영문 key - named = labels["column_overrides"].get(f"{fid}/{key}") or labels["columns"].get(key) or {} + for key in keys: # 찾는 차례: 그 표 열 목록(줄 이름 열 `@key` 는 표마다 제 이름) → 「파일id/열key」 → 「열key」 → 영문 key + named = ( + own_columns.get(key) + or labels["column_overrides"].get(f"{fid}/{key}") + or labels["columns"].get(key) + or {} + ) columns.append( { "key": key, diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index 7b5d77ef..73f28243 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -107,8 +107,10 @@ def test_묶음_표는_줄마다_이름_열(client: TestClient) -> None: assert slope["rows"][0]["성토"] == [0.3, 0.35, 0.4, 0.45, 0.5] cols = {c["key"]: c for c in slope["columns"]} assert cols["성토"]["label"] == "성토부 경사" and cols["성토"]["unit"] == "1:n" + assert cols["@key"]["label"] == "쌓기 방식" # 줄 이름 열은 표마다 제 이름(이름표 표 열 목록) fx = {c["key"]: c for c in _rows(client, file="fx", table="variables")["columns"]} assert fx["value"]["label"] == "환율" and fx["value"]["unit"] == "원" # 「fx/value」 덮어쓰기 + assert fx["@key"]["label"] == "통화" def test_숨김은_이름표의_보임에서(client: TestClient) -> None: @@ -167,8 +169,8 @@ def test_찾는_차례와_없는_이름은_영문_key( ) -def test_낱값은_파일마다_표_하나_설명류는_뺌(client: TestClient) -> None: - """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 설명·방침은 값이 아님.""" +def test_낱값은_파일마다_표_하나_설명은_뺌(client: TestClient) -> None: + """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 값/설명 가름은 이름표 `role`.""" files = _files(client) rates = files["rates"]["tables"] assert rates[-1]["id"] == "@values" @@ -183,20 +185,57 @@ def test_낱값은_파일마다_표_하나_설명류는_뺌(client: TestClient) "@value": False, "@path": True, } - master = {r["@path"] for r in _rows(client, file="work_item_master", table="@values")["rows"]} - assert master == {"stats"} # policy 는 방침 - assert "@values" not in {t["id"] for t in files["aliases"]["tables"]} # note 뿐 - assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 - for f in files.values(): - if "@values" in {t["id"] for t in f["tables"]}: - paths = [ - r["@path"] for r in _rows(client, file=f["id"], table="@values", size=500)["rows"] - ] - assert not [p for p in paths if tables.is_description(p)], f["id"] + assert "@values" not in { + t["id"] for t in files["work_item_master"]["tables"] + } # 방침·집계 = 설명 + assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 — 줄이 곧 값 + for entry in LABELS["files"]: # 보이는 것 = 이름표가 「값」 이라 한 것 그대로 + want = [g["key"] for g in entry["value_groups"] if g["role"] == "value"] + got = ( + _rows(client, file=entry["file_id"], table="@values", size=500)["rows"] + if "@values" in {t["id"] for t in files[entry["file_id"]]["tables"]} + else [] + ) + assert sorted(r["@path"] for r in got) == sorted(want), entry["file_id"] hit = _rows(client, file="rates", table="@values", q="산재") assert [r["@path"] for r in hit["rows"]] == ["variables/rate_sanjae"] +def test_이름표가_값_설명을_안_가른_마디는_미판정으로_보임( + client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """브레인 — 조용히 값으로 보이면 설명이 섞이고, 조용히 빼면 원가에 드는 값이 사라짐 → 값으로 보이되 드러나게.""" + entry = next(f for f in LABELS["files"] if f["file_id"] == "rates") + lagging = [dict(g) for g in entry["value_groups"]] + for g in lagging: + if g["key"] in ("variables/rate_vat", "variables/rate_goyong/base"): + del g["role"] # 이름표가 뒤처짐 + elif g["key"] == "variables/rate_care": + g["role"] = "formula" # 코드가 모르는 낱말도 미판정 + (tmp_path / "labels_2026-01-01.json").write_text( + json.dumps({**LABELS, "files": [{**entry, "value_groups": lagging}]}, ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr(tables, "LABEL_DIR", tmp_path) + values = _rows(client, file="rates", table="@values", size=500) + marks = {r["@path"]: r.get(tables.UNDECIDED_COLUMN) for r in values["rows"]} + undecided = {"variables/rate_vat", "variables/rate_goyong/base", "variables/rate_care"} + assert {p for p, m in marks.items() if m} == undecided + assert {marks[p] for p in undecided} == {tables.UNDECIDED} + assert tables.UNDECIDED_COLUMN in {c["key"] for c in values["columns"]} + assert not next(c for c in values["columns"] if c["key"] == tables.UNDECIDED_COLUMN)["hidden"] + + +def test_진짜_이름표엔_미판정이_없음() -> None: + undecided = [ + f"{entry['file_id']}::{r['@path']}" + for entry in LABELS["files"] + for r in tables._value_rows(entry, tables.ROOT / entry["path"], LABELS) + if r.get(tables.UNDECIDED_COLUMN) + ] + assert undecided == [], undecided + + def test_값_묶음_자리는_이름표와_양쪽으로_같음() -> None: not_labelled, label_only = [], [] for entry in LABELS["files"]: @@ -207,7 +246,7 @@ def test_값_묶음_자리는_이름표와_양쪽으로_같음() -> None: assert not_labelled == [] and label_only == [], (not_labelled, label_only) for entry in LABELS["files"]: # 이름도 이름표가 푼 그대로(찾는 차례 「파일id::key」 → 끝 조각) named = {g["key"]: g["name_ko"] for g in entry["value_groups"]} - rows = tables._value_rows(entry["file_id"], tables.ROOT / entry["path"], LABELS) + rows = tables._value_rows(entry, tables.ROOT / entry["path"], LABELS) assert [r["@name"] for r in rows] == [named[r["@path"]] for r in rows], entry["file_id"] From 9424cd212974943bae6a2d8ca957c806827a3632 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:28:09 +0900 Subject: [PATCH 12/48] =?UTF-8?q?feat(z01):=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF(=EC=A7=80=EB=AC=B8=20fingerprint=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8=20=C2=B7=20=E3=80=8C=EC=A0=95=EB=B3=B8=20=EC=95=84?= =?UTF-8?q?=EB=8B=98=E3=80=8D=20=EB=94=B1=EC=A7=80=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EC=82=B0=EB=AC=BC=EB=A7=8C)=20=C2=B7=20=EB=82=B1=EA=B0=92=20?= =?UTF-8?q?=EB=A7=88=EB=94=94(@values)=EB=8A=94=20=ED=91=9C=EB=93=A4=20?= =?UTF-8?q?=EB=B0=91=EC=97=90=20=C2=B7=20=ED=81=B0=20=ED=91=9C=20=EC=85=8B?= =?UTF-8?q?=20ORCA=20=ED=99=95=EC=9D=B8(40,000=EC=A4=84=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EB=81=9D=20?= =?UTF-8?q?=EC=AA=BD=20=EB=B0=94=EB=A1=9C=20=C2=B7=20=EA=B2=B9=EC=B9=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=80=20=EC=B5=9C=EC=8B=A0=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 6 +++++- Z01_MasterData/Z01_MasterData_UI_Page.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index ca287649..d2113c51 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -22,8 +22,12 @@ export interface MasterFile { tables: MasterTable[]; } +/** 표가 아닌 낱값(한 값짜리 요율 따위)을 모은 마디 — 여느 표처럼 이름·값 두 칸으로 옴. */ +export const VALUES_TABLE_ID = "@values"; + export interface MasterGroup { - key: "logic" | "base" | "byproduct" | "seed"; + /** fingerprint = 폴더 지문(_manifest) — 어느 판으로 셈했는지 못박는 자리(부산물 아님). */ + key: "logic" | "base_value" | "byproduct" | "seed" | "fingerprint"; label: string; files: MasterFile[]; } diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index b87be416..df8c6f5b 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -26,6 +26,7 @@ import { type MasterGroup, type MasterRows, type MasterTable, + VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; import { clampPage, @@ -92,7 +93,11 @@ function buildTree( file.label, file.label === file.key ? "" : file.key, ); - for (const table of file.tables) { + // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). + const ordered = [...file.tables].sort( + (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), + ); + for (const table of ordered) { const button = el("button", { className: "z01-master__table", attrs: { type: "button", title: table.key }, From 88007b1ad90d842f1e905cc5937dc447112f30ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:34:15 +0900 Subject: [PATCH 13/48] =?UTF-8?q?feat(z01):=20=EA=B3=81=EA=B0=92=2017=20?= =?UTF-8?q?=EC=9D=84=20=EA=B0=92=20=EC=AA=BD=EC=9C=BC=EB=A1=9C=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20`@undecided`=20=E3=80=8C=EB=AF=B8=ED=8C=90?= =?UTF-8?q?=EC=A0=95=E3=80=8D=20=EB=91=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 곁값 = **없으면 값의 뜻이 안 서는 칸**(브레인 ②) — 값 단위 8(할증 둘·기계 넷·유가 둘) · 기준일 2(시세는 기준일이 값의 반) · 걸치는 범위 3 · 목재 밑수(채적 ㎥) · 기계 지급보증 밑수 비고 · 요율 적용 여부 2(임도 줄 고름·임도에 걸리는지) ⇒ 값 49 → **66** · 설명 123 → 106 - `@undecided` 「미판정」 열을 낱값 마디에 둠 · 값도 설명도 아닌 미결 셋에 `undecided: true` (목재 확정 대기 · 재료 할증 못 찾은 것 · 할증 확정 대기 후보 표) — **갈래를 임의로 안 못박음** - 낱말은 `kind: "value" | "doc"` 그대로(브레인 ③ 되물림) · `value_kinds`·`synthetic_tables` 도 그대로 - 시험 28건(2건 늘) — 곁값이 설명으로 숨거나 미결이 갈래에 박히면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 57 +++++++++------- resources/tester/test_master_labels_cover.py | 68 ++++++++++++++++++- 2 files changed, 99 insertions(+), 26 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index e7a836e7..e59ae89f 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T19:23:25+09:00", + "generated_at": "2026-09-15T19:33:14+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -13,7 +13,7 @@ "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", "row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).", - "value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·표 머리의 키·단위·기준일) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명(2026-09-15 브레인 ②)." + "value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·집계·표 머리의 줄 키·담은 꼴) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명. 곁값(값 단위·기준일·걸치는 범위)은 **값 쪽**이다 — 없으면 값의 뜻이 안 선다. `undecided: true` 는 값도 설명도 아닌 미결이라 판정을 기다린다(2026-09-15 브레인 ②)." }, "lookup_order": { "column": [ @@ -81,6 +81,12 @@ "name_ko": "원문 자리", "unit": "", "visible": false + }, + { + "key": "@undecided", + "name_ko": "미판정", + "unit": "", + "visible": true } ], "note": "`kind: \"value\"` 인 값 묶음만 여기 선다. `kind: \"doc\"` 은 안 보인다." @@ -93,8 +99,8 @@ "value_groups": 172, "unknown": 0, "value_groups_by_kind": { - "doc": 123, - "value": 49 + "doc": 106, + "value": 66 } }, "files": [ @@ -645,7 +651,7 @@ "key": "variables/surcharge_labor/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "kind": "doc" + "kind": "value" }, { "key": "variables/surcharge_mat/duplicate_application_prohibited", @@ -663,7 +669,7 @@ "key": "variables/surcharge_mat/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "kind": "doc" + "kind": "value" } ] }, @@ -723,7 +729,8 @@ "unit": "", "visible": true } - ] + ], + "undecided": true }, { "key": "rates_pct", @@ -816,7 +823,8 @@ "key": "not_found", "name_ko": "못 찾은 것", "summary": "두 품셈을 다 뒤졌으나 이름이 없는 것.", - "kind": "doc" + "kind": "doc", + "undecided": true }, { "key": "observed_practice", @@ -1221,7 +1229,7 @@ "key": "basis_unit", "name_ko": "밑수", "summary": "값이 무엇 하나당인지.", - "kind": "doc" + "kind": "value" }, { "key": "note", @@ -1233,7 +1241,8 @@ "key": "pending_user", "name_ko": "사용자 확정 대기", "summary": "아직 사람이 답해야 닫히는 물음.", - "kind": "doc" + "kind": "doc", + "undecided": true }, { "key": "source", @@ -1585,7 +1594,7 @@ "key": "scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "kind": "doc" + "kind": "value" } ] }, @@ -3097,7 +3106,7 @@ "key": "variables/mach_fuel_rate/unit", "name_ko": "값 단위", "summary": "연료량은 L/시간이다.", - "kind": "doc" + "kind": "value" }, { "key": "variables/mach_loss_coef/key", @@ -3109,7 +3118,7 @@ "key": "variables/mach_loss_coef/unit", "name_ko": "값 단위", "summary": "손료계수는 1/시간이다.", - "kind": "doc" + "kind": "value" }, { "key": "variables/mach_operator_map/note", @@ -3127,13 +3136,13 @@ "key": "variables/mach_price/unit", "name_ko": "값 단위", "summary": "취득가는 천원 단위다.", - "kind": "doc" + "kind": "value" }, { "key": "variables/mach_rock_adj/unit", "name_ko": "값 단위", "summary": "할증은 %다.", - "kind": "doc" + "kind": "value" } ] }, @@ -3600,13 +3609,13 @@ "key": "variables/oil_diesel/date", "name_ko": "기준일", "summary": "값이 선 날.", - "kind": "doc" + "kind": "value" }, { "key": "variables/oil_diesel/scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "kind": "doc" + "kind": "value" }, { "key": "variables/oil_diesel/source_product_code", @@ -3624,19 +3633,19 @@ "key": "variables/oil_diesel/unit", "name_ko": "값 단위", "summary": "단가는 원/L 이다.", - "kind": "doc" + "kind": "value" }, { "key": "variables/oil_gasoline/date", "name_ko": "기준일", "summary": "값이 선 날.", - "kind": "doc" + "kind": "value" }, { "key": "variables/oil_gasoline/scope", "name_ko": "걸치는 범위", "summary": "어디까지 쓰는 값인지.", - "kind": "doc" + "kind": "value" }, { "key": "variables/oil_gasoline/source_product_code", @@ -3654,7 +3663,7 @@ "key": "variables/oil_gasoline/unit", "name_ko": "값 단위", "summary": "단가는 원/L 이다.", - "kind": "doc" + "kind": "value" } ] }, @@ -4135,7 +4144,7 @@ "key": "variables/rate_environment/forest_road_selection_status", "name_ko": "임도 줄 고름 상태", "summary": "임도에 어느 줄을 쓸지 고름이 끝났는지.", - "kind": "doc" + "kind": "value" }, { "key": "variables/rate_environment/minimum_estimated_amount_krw", @@ -4153,7 +4162,7 @@ "key": "variables/rate_equipment_payment_guarantee/base_note", "name_ko": "밑수 비고", "summary": "밑수를 그렇게 적은 까닭.", - "kind": "doc" + "kind": "value" }, { "key": "variables/rate_goyong/base", @@ -4207,7 +4216,7 @@ "key": "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", "name_ko": "임도에 걸리는지", "summary": "보통 임도 규모에서 이 항목이 서는지.", - "kind": "doc" + "kind": "value" }, { "key": "variables/rate_profit/base", diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index d13ed374..26ed63b0 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -347,8 +347,8 @@ def test_값_갈래_잣대와_낱값_마디가_적혀_있다(labels): node = labels["synthetic_tables"]["@values"] assert node["name_ko"] == "낱값" assert node["summary"] == "표가 아닌 한 값들" - assert [c["key"] for c in node["columns"]] == ["@name", "@value", "@path"] - assert [c["name_ko"] for c in node["columns"]] == ["항목", "값", "원문 자리"] + assert [c["key"] for c in node["columns"]] == ["@name", "@value", "@path", "@undecided"] + assert [c["name_ko"] for c in node["columns"]] == ["항목", "값", "원문 자리", "미판정"] assert node["columns"][2]["visible"] is False @@ -383,3 +383,67 @@ def test_세어_둔_값_갈래_수가_실제와_같다(labels): for group in entry["value_groups"]: counted[group["kind"]] = counted.get(group["kind"], 0) + 1 assert counted == labels["counts"]["value_groups_by_kind"] + + +def test_곁값은_값_쪽에_선다(labels): + """값 단위·기준일·걸치는 범위는 **없으면 값의 뜻이 안 선다** — 설명으로 숨기면 + 「유가 1,858」만 떠서 리터인지 드럼인지, 언제 값인지 모른다(2026-09-15 브레인 ②).""" + want = { + "coef": ("variables/surcharge_labor/unit", "variables/surcharge_mat/unit"), + "mach_base": ( + "variables/mach_fuel_rate/unit", + "variables/mach_loss_coef/unit", + "variables/mach_price/unit", + "variables/mach_rock_adj/unit", + ), + "oil_regional": ( + "variables/oil_diesel/unit", + "variables/oil_gasoline/unit", + "variables/oil_diesel/date", + "variables/oil_gasoline/date", + "variables/oil_diesel/scope", + "variables/oil_gasoline/scope", + ), + "revetment_sabang": ("scope",), + "timber_structure_class": ("basis_unit",), + "rates": ( + "variables/rate_equipment_payment_guarantee/base_note", + "variables/rate_environment/forest_road_selection_status", + "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", + ), + } + by_file = { + f["file_id"]: {g["key"]: g["kind"] for g in f["value_groups"]} for f in labels["files"] + } + bad = [] + for file_id, keys in want.items(): + for key in keys: + if by_file.get(file_id, {}).get(key) != "value": + bad.append(f"{file_id}::{key}") + assert not bad, f"곁값이 설명으로 숨었다: {bad}" + # 브레인 쪽지는 「16 쯤」이라 적었으나 짚어 준 자리를 세면 17 이다 + # (unit 8 · date 2 · scope 3 · basis_unit · 밑수 비고 · 요율 적용여부 2). + assert sum(len(v) for v in want.values()) == 17 + + +def test_미결은_미판정으로_표시된다(labels): + """값도 설명도 아닌 「미결」류 — 사용자가 트리를 본 뒤 정한다. 갈래를 임의로 못박지 않는다.""" + marked = { + f"{f['file_id']}::{g['key']}" + for f in labels["files"] + for g in f["value_groups"] + if g.get("undecided") + } | { + f"{f['file_id']}::{t['key']}" + for f in labels["files"] + for t in f["tables"] + if t.get("undecided") + } + assert marked == { + "timber_structure_class::pending_user", + "material_surcharge::not_found", + "material_surcharge::candidates_pending_user/items", + } + column = labels["synthetic_tables"]["@values"]["columns"][3] + assert column["key"] == "@undecided" + assert column["name_ko"] == "미판정" From 0749489122341c8cae198eb2a893658bbf77161d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 19:36:36 +0900 Subject: [PATCH 14/48] =?UTF-8?q?fix(z01):=20=EA=B0=92=C2=B7=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EB=82=B1=EB=A7=90=EC=9D=84=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=ED=8C=90(kind=20=C2=B7=20value/doc)=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=E2=80=94=20dev=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C(=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=208fb1952b)=EC=99=80=20=EB=A9=94=EC=9D=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20role=20=EC=96=B4=EA=B8=8B=EB=82=A8=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=82=B1=EA=B0=92=20=ED=91=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=E3=80=8C=EB=82=B1=EA=B0=92=E3=80=8D=20=EA=B3=BC=20?= =?UTF-8?q?=EC=97=B4=20=EC=9D=B4=EB=A6=84(=ED=95=AD=EB=AA=A9=20=C2=B7=20?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80)=EC=9D=80=20=EC=9D=B4=EB=A6=84=ED=91=9C=20sy?= =?UTF-8?q?nthetic=5Ftables=20=EC=97=90=EC=84=9C=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=BD=94=EB=93=9C=20=EC=88=A8=EA=B9=80=20=EB=BA=8C=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EA=B0=92=EC=9D=80=20?= =?UTF-8?q?=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=20=E3=80=8C=EA=B0=92=E3=80=8D=20=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=EC=A7=80=EB=A1=9C=20=EC=85=88(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=E3=89=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_Tables.py | 16 ++++++++++------ resources/tester/test_z01_master_data.py | 22 ++++++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py index 55b94e2e..0bae789f 100644 --- a/Z01_MasterData/Z01_MasterData_Tables.py +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -24,12 +24,11 @@ DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 500 ROW_KEY = "@key" VALUES_TABLE = "@values" -_CODE_HIDDEN = {"@path"} # 낱값 표의 영문 자리 — 이름표에 없을 때만 -# 값 묶음 갈래 — 이름표 `value_groups[].role`(데스크탑 서브) · 낱말이 바뀌면 이 자리만 갈아끼움 -ROLE_FIELD = "role" +# 값 묶음 갈래 — 이름표 `value_groups[].kind`(데스크탑 서브 8fb1952b) · 낱말이 바뀌면 이 자리만 갈아끼움 +ROLE_FIELD = "kind" SHOWN_ROLE = "value" # 원가·수량에 드는 값 — 낱값 표에 보임 -HIDDEN_ROLE = "note" # 읽으라고 적은 글 — 안 보임 +HIDDEN_ROLE = "doc" # 읽으라고 적은 글 — 안 보임 # 이름표가 안 가른(또는 모르는 낱말) 마디 — 조용히 빼지도 섞지도 않고 값으로 보이되 드러냄(브레인) UNDECIDED_COLUMN = "@undecided" UNDECIDED = "미판정" @@ -155,6 +154,9 @@ def tree() -> dict[str, Any]: if path is None: continue named = {t["key"]: t.get("name_ko") or "" for t in entry.get("tables") or []} + named.update( + {k: v.get("name_ko") or "" for k, v in (labels.get("synthetic_tables") or {}).items()} + ) kind = entry.get("kind") or "" group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []}) counts = {tid: len(rows) for tid, rows in tables_of(path).items()} @@ -188,7 +190,9 @@ def rows( table = tables_of(path).get(tid) if path else None if table is None: return None - own = next((t for t in entry.get("tables") or [] if t.get("key") == tid), {}) + own = (labels.get("synthetic_tables") or {}).get(tid) or next( + (t for t in entry.get("tables") or [] if t.get("key") == tid), {} + ) # 낱값 표처럼 API 가 지은 표는 이름표 `synthetic_tables` own_columns = {c.get("key"): c for c in own.get("columns") or []} keys: dict[str, None] = {} for r in table: @@ -214,7 +218,7 @@ def rows( "key": key, "label": named.get("name_ko") or key, "unit": named.get("unit") or "", - "hidden": named.get("visible") is False if named else key in _CODE_HIDDEN, + "hidden": named.get("visible") is False, } ) return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)} diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index 73f28243..f7f00f68 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -170,27 +170,29 @@ def test_찾는_차례와_없는_이름은_영문_key( def test_낱값은_파일마다_표_하나_설명은_뺌(client: TestClient) -> None: - """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 값/설명 가름은 이름표 `role`.""" + """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 값/설명 가름은 이름표 `kind`.""" files = _files(client) rates = files["rates"]["tables"] - assert rates[-1]["id"] == "@values" + assert rates[-1]["id"] == "@values" and rates[-1]["label"] == "낱값" # 이름표 synthetic_tables values = _rows(client, file="rates", table="@values", size=500) assert values["total"] == rates[-1]["row_count"] by_path = {r["@path"]: r for r in values["rows"]} sanjae = by_path["variables/rate_sanjae"] assert sanjae["@value"] == {"rate_percent": 3.56, "base": "total_labor_cost"} assert sanjae["@name"] == "산재보험료" - assert {c["key"]: c["hidden"] for c in values["columns"]} == { - "@name": False, - "@value": False, - "@path": True, + assert {c["key"]: (c["label"], c["hidden"]) for c in values["columns"]} == { + "@name": ("항목", False), + "@value": ("값", False), + "@path": ("원문 자리", True), } assert "@values" not in { t["id"] for t in files["work_item_master"]["tables"] } # 방침·집계 = 설명 assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 — 줄이 곧 값 - for entry in LABELS["files"]: # 보이는 것 = 이름표가 「값」 이라 한 것 그대로 - want = [g["key"] for g in entry["value_groups"] if g["role"] == "value"] + for entry in LABELS[ + "files" + ]: # 보이는 것 = 이름표가 「값」 이라 한 것 그대로(수로 박지 않음 — 이름표가 늘어도 맞음) + want = [g["key"] for g in entry["value_groups"] if g["kind"] == "value"] got = ( _rows(client, file=entry["file_id"], table="@values", size=500)["rows"] if "@values" in {t["id"] for t in files[entry["file_id"]]["tables"]} @@ -209,9 +211,9 @@ def test_이름표가_값_설명을_안_가른_마디는_미판정으로_보임( lagging = [dict(g) for g in entry["value_groups"]] for g in lagging: if g["key"] in ("variables/rate_vat", "variables/rate_goyong/base"): - del g["role"] # 이름표가 뒤처짐 + del g["kind"] # 이름표가 뒤처짐 elif g["key"] == "variables/rate_care": - g["role"] = "formula" # 코드가 모르는 낱말도 미판정 + g["kind"] = "formula" # 코드가 모르는 낱말도 미판정 (tmp_path / "labels_2026-01-01.json").write_text( json.dumps({**LABELS, "files": [{**entry, "value_groups": lagging}]}, ensure_ascii=False), encoding="utf-8", From d8f4ac024005e8e494e4d4fbef141650b1db28cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 21:47:43 +0900 Subject: [PATCH 15/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=84=AF=EC=9D=84=20=ED=95=9C=20=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EB=8A=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EC=97=B4=EC=87=A0=C2=B7null?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `merged_tables` 다섯(노임·자재·유가·제비율·기계) — 열 62. **값은 안 옮김**, 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담음. 계산값 넷은 기계에만 있고 `editable: false` - `machine_cost_chain` 다섯 걸음 — 손료(취득가×1,000×손료계수) · 연료(연료량×유가×(1+잡재료)) · 조종원(일 노임÷8×제수당) · 암 할증 · 시간당 기계경비. **밑값 11 / 계산값 4** 를 가름 · 기계가 유가·노임 표를 물어 갱신 차례가 갈림을 적음 - `merge_findings` 네 갈래 — 이름 다른 같은 열 2 · 한쪽에만 있는 열 4 · 단위·축 어긋남 4 · 부딪힘 2 - `row_keys` 다섯 판정(브레인 ①) — 자재 `item_code`·기계 `machine_code` 는 전수 겹침 0 으로 **있음** · 노임은 두 코드 공간이라 `출처+직종코드` 로 묶어야 함 · 유가·제비율은 **줄 열쇠 없음**(지어내지 않음) - `null_is_real` 여섯(브레인 ②) — ⭐ 노임 일 노임이 **30 줄에 아예 없음**(미조사가 제값) · 기계 운전경비에 **명시 null 12** · 되돌리기 null 과 뜻이 겹치는 자리를 규칙과 함께 적음 - 시험 36건(8건 늘) — 계산값이 기계 밖으로 새거나 줄 열쇠·null 판정이 빠지면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 822 +++++++++++++++++- resources/tester/test_master_labels_cover.py | 91 ++ 2 files changed, 912 insertions(+), 1 deletion(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index e59ae89f..ef78ff05 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T19:33:14+09:00", + "generated_at": "2026-09-15T21:46:51+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -12,6 +12,7 @@ "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", + "merged_tables_rule": "`merged_tables` 는 **기초단가 다섯**(노임·자재·유가·제비율·기계)을 화면이 한 표씩으로 보이려고 여러 원본 표를 모은 자리다. ⚠ **값을 옮기지 않는다** — 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담는다. `editable: false` 는 다른 값에서 나온 계산값이라 사람이 못 고친다. 기계는 한 표로 안 모이므로 `machine_cost_chain` 이 사슬을 적는다 (2026-09-15 사용자 지시 · 브레인).", "row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).", "value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·집계·표 머리의 줄 키·담은 꼴) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명. 곁값(값 단위·기준일·걸치는 범위)은 **값 쪽**이다 — 없으면 값의 뜻이 안 선다. `undecided: true` 는 값도 설명도 아닌 미결이라 판정을 기다린다(2026-09-15 브레인 ②)." }, @@ -49,6 +50,823 @@ "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." } }, + "merged_tables": [ + { + "key": "labor", + "name_ko": "노임", + "summary": "직종마다 하루 노임 한 표. 건설업·제조업 두 공표를 한 자리에 모은다.", + "sources": [ + "labor_const::variables/labor_rate/records", + "labor_mfg::variables/labor_mfg/records" + ], + "rows_note": "건설업 132 + 제조업 129 = 261 줄", + "columns": [ + { + "key": "source", + "name_ko": "출처", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "건설업(대한건설협회) · 제조업(중소기업중앙회). ⚠ 이 칸이 없으면 두 코드 공간이 섞인다." + }, + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "occupation_name", + "name_ko": "직종명", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "industry", + "name_ko": "업종", + "unit": "", + "visible": true, + "editable": true, + "from": "제조업만" + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다", + "note": "둘 다 KRW/day — 어긋남 없음." + }, + { + "key": "hours_per_day", + "name_ko": "1일 근로시간", + "unit": "시간", + "visible": true, + "editable": true, + "from": "건설업만" + }, + { + "key": "daily_wage_krw", + "name_ko": "일 노임", + "unit": "원", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "status", + "name_ko": "공표 상태", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "reliability", + "name_ko": "신뢰도 기호", + "unit": "", + "visible": true, + "editable": true, + "from": "건설업만", + "note": "* 조사현장 5개 미만 · ** 미조사. 뜻풀이가 원본에 있다." + }, + { + "key": "source_flag", + "name_ko": "원문 표시", + "unit": "", + "visible": true, + "editable": true, + "from": "제조업만", + "note": "`*` 16 줄. ⚠ 뜻풀이가 원본에 없어 신뢰도 기호와 한 칸으로 못 묶는다." + } + ] + }, + { + "key": "material", + "name_ko": "자재 단가", + "summary": "물품마다 규격·단위·단가 한 표. 원본이 하나라 합칠 것이 없다.", + "sources": [ + "mat_price_public::variables/mat_price/records" + ], + "rows_note": "6,999 줄 — 다섯 가운데 가장 크다", + "columns": [ + { + "key": "item_code", + "name_ko": "물품코드", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "classification_code", + "name_ko": "물품분류번호", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "classification_name", + "name_ko": "물품분류명", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "price_krw", + "name_ko": "단가", + "unit": "원", + "visible": true, + "editable": true + }, + { + "key": "vat_basis", + "name_ko": "부가세 기준", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "price_type", + "name_ko": "가격 종류", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "delivery_condition", + "name_ko": "납품 조건", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "business_division_name", + "name_ko": "사업구분", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "field", + "name_ko": "분야", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "notice_datetime", + "name_ko": "공고 일시", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "notice_number", + "name_ko": "공고번호", + "unit": "", + "visible": false, + "editable": true + }, + { + "key": "business_division_code", + "name_ko": "사업구분 코드", + "unit": "", + "visible": false, + "editable": true + } + ] + }, + { + "key": "oil", + "name_ko": "유가", + "summary": "유종 × 지역 한 표. 전국평균 판과 시도별 판을 한 자리에 모은다.", + "sources": [ + "oil::variables", + "oil_regional::variables/oil_gasoline/records", + "oil_regional::variables/oil_diesel/records" + ], + "rows_note": "전국평균 2 + 시도별 34 = 36 줄", + "columns": [ + { + "key": "fuel", + "name_ko": "유종", + "unit": "", + "visible": true, + "editable": true, + "from": "원본 키", + "note": "휘발유 · 경유." + }, + { + "key": "source", + "name_ko": "출처", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "전국평균 판 · 시도별 판. 기준일이 판마다 다르다." + }, + { + "key": "region_code", + "name_ko": "시도코드", + "unit": "", + "visible": true, + "editable": true, + "from": "시도판", + "note": "전국평균 판은 `00` 으로 맞춘다." + }, + { + "key": "region_name", + "name_ko": "시도명", + "unit": "", + "visible": true, + "editable": true, + "from": "시도판" + }, + { + "key": "price_krw_per_l", + "name_ko": "단가", + "unit": "원/L", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "date", + "name_ko": "기준일", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다", + "note": "⚠ 판마다 다르다 — 같은 표에 두 날짜가 선다." + }, + { + "key": "source_product_name", + "name_ko": "원천 제품명", + "unit": "", + "visible": false, + "editable": true, + "from": "둘 다" + }, + { + "key": "source_product_code", + "name_ko": "원천 제품코드", + "unit": "", + "visible": false, + "editable": true, + "from": "둘 다" + } + ] + }, + { + "key": "rates", + "name_ko": "제비율", + "summary": "법정경비·일반관리비·이윤·부가세 요율 한 표. 원본 19 벌을 한 줄씩 펴서 모은다.", + "sources": [ + "rates::variables/* (19 벌)" + ], + "rows_note": "한 값짜리 8 + 구간표 11 벌을 펴서 약 190 줄", + "columns": [ + { + "key": "item_key", + "name_ko": "항목 키", + "unit": "", + "visible": false, + "editable": true, + "from": "원본 키" + }, + { + "key": "item_name", + "name_ko": "항목", + "unit": "", + "visible": true, + "editable": true, + "from": "이름표" + }, + { + "key": "variant", + "name_ko": "벌", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "한 항목이 표를 둘 가진 경우 — 일반관리비(토목/전문) · 기계 지급보증(종합/전문) · 환경보전비(임도 후보/전 공종)." + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true, + "editable": true, + "from": "6 벌" + }, + { + "key": "amount_bracket", + "name_ko": "금액 구간", + "unit": "", + "visible": true, + "editable": true, + "from": "9 벌", + "note": "⚠ **축이 넷인데 한 칸으로 접힌다** — 추정금액(고용) · 추정가격(하도급·일반관리비·이윤) · 직접공사비(간접노무·기타경비·이행보증) · 대상액(안전관리비). 어느 금액인지는 곁 칸이 말한다." + }, + { + "key": "bracket_basis", + "name_ko": "구간이 무슨 금액인가", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "위 칸을 접으면 사라지는 것 — 반드시 함께 둔다." + }, + { + "key": "duration_bracket", + "name_ko": "공사기간 구간", + "unit": "", + "visible": true, + "editable": true, + "from": "2 벌" + }, + { + "key": "grade", + "name_ko": "등급", + "unit": "", + "visible": true, + "editable": true, + "from": "고용보험만" + }, + { + "key": "year", + "name_ko": "연도", + "unit": "", + "visible": true, + "editable": true, + "from": "국민연금만" + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true, + "editable": true, + "from": "18 벌", + "note": "이행보증은 요율이 아니라 산식이라 이 칸이 빈다." + }, + { + "key": "base_amount_krw", + "name_ko": "기초액", + "unit": "원", + "visible": true, + "editable": true, + "from": "안전관리비만" + }, + { + "key": "formula", + "name_ko": "산식", + "unit": "", + "visible": true, + "editable": true, + "from": "이행보증만" + }, + { + "key": "base", + "name_ko": "밑수", + "unit": "", + "visible": true, + "editable": true, + "from": "19 벌 다", + "note": "이 요율을 어디에 곱하는가." + }, + { + "key": "minimum_krw", + "name_ko": "적용 하한", + "unit": "원", + "visible": true, + "editable": true, + "from": "3 벌", + "note": "이 금액에 못 미치면 줄이 안 선다 — 안전관리비 2천만 · 환경보전비 1억 · 퇴직공제 1억." + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true, + "editable": true, + "from": "원본 곁글" + } + ] + }, + { + "key": "machine", + "name_ko": "기계경비", + "summary": "기종 한 줄 + 시간당 단가. ⚠ **한 표로 안 모인다** — 시간당 단가가 다섯 표를 물어 나오는 계산값이다.", + "sources": [ + "mach_base::variables/mach_price/records", + "mach_base::variables/mach_loss_coef/records", + "mach_base::variables/mach_fuel_rate/parsed_records", + "mach_base::variables/mach_operator_map/explicit_mappings", + "mach_base::variables/mach_rock_adj/rules", + "machine_operating::records" + ], + "rows_note": "취득가 613 줄이 뼈대 · 손료계수 387 · 연료 214 · 조종원 121 이 붙는다(안 붙는 줄은 빈 칸)", + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "price_thousand_krw", + "name_ko": "취득가", + "unit": "천원", + "visible": true, + "editable": true, + "from": "취득가", + "note": "⭐ 밑값." + }, + { + "key": "loss_coefficient_per_hour", + "name_ko": "시간당 손료계수", + "unit": "", + "visible": true, + "editable": true, + "from": "손료계수", + "note": "⭐ 밑값." + }, + { + "key": "fuel_type", + "name_ko": "연료 종류", + "unit": "", + "visible": true, + "editable": true, + "from": "연료" + }, + { + "key": "fuel_rate_l_per_hour", + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true, + "editable": true, + "from": "연료", + "note": "⭐ 밑값." + }, + { + "key": "misc_material_percent", + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true, + "editable": true, + "from": "연료", + "note": "⭐ 밑값." + }, + { + "key": "operator_occupation_code", + "name_ko": "조종원 직종", + "unit": "", + "visible": true, + "editable": true, + "from": "조종원", + "note": "⭐ 밑값 — 노임 표를 가리킨다." + }, + { + "key": "operator_person_per_day", + "name_ko": "조종원", + "unit": "인/일", + "visible": true, + "editable": true, + "from": "조종원", + "note": "⭐ 밑값." + }, + { + "key": "rock_adjust_pct", + "name_ko": "암 작업 할증", + "unit": "%", + "visible": true, + "editable": true, + "from": "암석보정", + "note": "⭐ 밑값 — 기계 묶음별." + }, + { + "key": "hourly_loss_krw", + "name_ko": "시간당 손료", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "취득가 × 1,000 × 손료계수." + }, + { + "key": "hourly_fuel_krw", + "name_ko": "시간당 연료·잡재료", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "연료량 × 유가 × (1 + 잡재료비율). 유가는 **유가 표**에서 온다." + }, + { + "key": "hourly_operator_krw", + "name_ko": "시간당 조종원", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "일 노임 ÷ 8 × 제수당 계수. 일 노임은 **노임 표**에서 온다." + }, + { + "key": "hourly_total_krw", + "name_ko": "시간당 기계경비", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "손료 + 운전경비. 수송비는 여기 안 든다." + } + ] + } + ], + "machine_cost_chain": { + "note": "기계경비 = 기계손료 + 운전경비 + 수송비(품셈 8-1-6). ⭐ **밑값만 고칠 수 있다** — 계산값은 밑값이 바뀌면 따라 움직인다.", + "steps": [ + { + "step": "① 손료", + "formula": "취득가(천원) × 1,000 × 시간당 손료계수", + "inputs": [ + "취득가", + "시간당 손료계수" + ], + "note": "손료계수는 상각·정비·관리 계수의 합이다. 그 셋과 내용시간·연간표준가동시간도 밑값이라 함께 보인다." + }, + { + "step": "② 운전경비 — 연료", + "formula": "시간당 연료량(L) × 유가(원/L) × (1 + 잡재료비율)", + "inputs": [ + "시간당 연료량", + "잡재료비", + "유가 표" + ], + "note": "유가는 이 표에 없다 — **유가 표**에서 온다. 그래서 유가를 고치면 모든 기종이 움직인다." + }, + { + "step": "③ 운전경비 — 조종원", + "formula": "일 노임 ÷ 8시간 × 제수당 계수 × 조종원(인/일)", + "inputs": [ + "조종원 직종", + "조종원 인/일", + "노임 표" + ], + "note": "일 노임은 이 표에 없다 — **노임 표**에서 온다. 8시간은 품셈 8-1-6 단서다." + }, + { + "step": "④ 암 작업 할증", + "formula": "위 값 × (1 + 암 할증률)", + "inputs": [ + "암 작업 할증" + ], + "note": "기계 묶음별로 붙는다(불도저·굴착기·덤프)." + }, + { + "step": "⑤ 시간당 기계경비", + "formula": "① + ② + ③ (④ 를 걸친 뒤)", + "inputs": [], + "note": "⚠ **수송비는 안 든다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이다." + } + ], + "editable_rule": "밑값 = 취득가 · 손료계수(와 그 속 계수들) · 연료량 · 잡재료비율 · 조종원 직종과 인수 · 암 할증률. 계산값 = 시간당 손료 · 시간당 연료 · 시간당 조종원 · 시간당 기계경비. ⚠ 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없게 된다 — 화면에서 막는다.", + "cross_table": "기계 표는 **유가 표와 노임 표를 문다**. 그 둘이 갱신되면 기계 시간당 단가가 다 바뀐다 — 갱신 차례는 노임·유가가 먼저, 기계가 나중이다." + }, + "merge_findings": { + "renamed_same_column": [ + { + "where": "노임", + "columns": [ + "labor_const.reliability", + "labor_mfg.source_flag" + ], + "verdict": "합치지 않음", + "why": "둘 다 원문 별표지만 뜻이 다르다 — 건설업은 「조사현장 5개 미만(*)·미조사(**)」로 뜻풀이가 있고, 제조업 `*`(16 줄)는 원본에 뜻풀이가 없다. 한 칸으로 묶으면 뜻이 섞인다." + }, + { + "where": "유가", + "columns": [ + "oil.value", + "oil_regional.value" + ], + "verdict": "합침", + "why": "둘 다 원/L 단가다. 이름만 `value` 로 같고 뜻도 같다 — 「단가」 한 칸으로 묶는다." + } + ], + "one_side_only": [ + { + "where": "노임", + "only_in": "건설업", + "columns": [ + "hours_per_day", + "reliability" + ] + }, + { + "where": "노임", + "only_in": "제조업", + "columns": [ + "industry", + "variation_coefficient", + "source_flag" + ] + }, + { + "where": "유가", + "only_in": "시도판", + "columns": [ + "sido_code", + "sido_name" + ], + "note": "전국평균 판은 시도코드 `00`·시도명 「전국」으로 맞춘다." + }, + { + "where": "제비율", + "only_in": "일부 벌", + "columns": [ + "grade", + "year", + "base_amount_krw", + "formula", + "duration_bracket" + ], + "note": "19 벌이 축을 제각각 쓴다 — 안 쓰는 벌에서는 빈 칸이다." + } + ], + "unit_or_axis_mismatch": [ + { + "where": "제비율", + "what": "금액 구간 축이 넷", + "detail": "추정금액(고용보험) · 추정가격(하도급보증·일반관리비·이윤) · 직접공사비(간접노무비·기타경비·이행보증) · 대상액(안전관리비). 한 칸으로 접으면 「어느 금액인가」가 사라지므로 `bracket_basis` 칸을 함께 둔다." + }, + { + "where": "제비율", + "what": "이행보증만 요율이 아니라 산식", + "detail": "`(직접공사비 × 0.0108%) × 공사연수` 꼴이라 `rate_percent` 가 빈다. `formula` 칸으로 받는다." + }, + { + "where": "기계", + "what": "취득가는 천원, 나머지 금액은 원", + "detail": "원본이 천원 단위다. 한 표에 섞이면 1,000배 틀린다 — 열 이름에 단위를 못박는다." + }, + { + "where": "노임", + "what": "단위는 어긋나지 않음", + "detail": "둘 다 `KRW/day` 다(건설 132 · 제조 129 전수 확인)." + } + ], + "collision": [ + { + "where": "노임", + "what": "직종코드 공간이 다르다", + "detail": "건설업은 네 자리(1001~), 제조업은 한두 자리(1·2·3…). **지금은 겹치는 코드가 0** 이지만 같은 칸에 넣으면 나중에 부딪힌다 — `source` 칸을 반드시 함께 둔다." + }, + { + "where": "유가", + "what": "「전국」이 두 줄 선다", + "detail": "전국평균 판(휘발유 1,863.25 · 경유 1,846.39 · 2026-08-14)과 시도판 코드 `00`(1,858.93 · 1,843.79 · 2026-09-09)이 **값도 기준일도 다르다**. ⚠ 어느 쪽을 쓸지는 판정이 필요하다 — 이름표는 두 줄을 그대로 보이고 `source` 로 가른다." + } + ] + }, + "row_keys": [ + { + "table": "labor", + "name_ko": "노임", + "verdict": "있음(묶어야 함)", + "key": [ + "source", + "occupation_code" + ], + "checked": "건설업 132 줄·제조업 129 줄 각각 직종코드 겹침 0(전수). 다만 코드 공간이 달라(건설 네 자리 1001~ · 제조 한두 자리 1·2·3) **출처 칸과 묶어야** 한 표에서 안 부딪힌다." + }, + { + "table": "material", + "name_ko": "자재 단가", + "verdict": "있음", + "key": [ + "item_code" + ], + "checked": "6,999 줄 물품코드 겹침 0(전수). 그대로 열쇠로 쓸 수 있다." + }, + { + "table": "oil", + "name_ko": "유가", + "verdict": "없음 — 묶어야 함", + "key": [ + "fuel", + "source", + "region_code" + ], + "checked": "원본이 유종마다 따로 선 표라 줄에는 시도코드뿐이다(유종별 17 줄 겹침 0). 유종은 표 이름에, 출처는 판에 있어 **줄 안에 없다** — 셋을 묶어야 줄이 하나로 가려진다." + }, + { + "table": "rates", + "name_ko": "제비율", + "verdict": "없음 — 조건으로만 가려짐", + "key": [ + "item_key", + "variant", + "(조건 열들)" + ], + "checked": "19 벌에 id 칸이 아예 없다. 벌마다 조건 묶음으로 줄이 가려지는 것은 확인했다(14 개 구간표 전수 겹침 0). 다만 **조건 축이 벌마다 다르다** — 등급·연도·공사 종류·공사기간·금액 구간 넷. 한 끈으로 쓰려면 항목키+벌+조건을 이어야 한다." + }, + { + "table": "machine", + "name_ko": "기계경비", + "verdict": "있음", + "key": [ + "machine_code" + ], + "checked": "취득가 613 · 손료계수 387 · 연료 214 · 운전경비 92 **네 표 모두 기계코드 겹침 0**(전수). 취득가가 가장 넓어 뼈대가 되고 나머지가 붙는다." + } + ], + "null_is_real": { + "columns": [ + { + "table": "labor", + "column": "daily_wage_krw", + "rows": 30, + "shape": "칸이 아예 없음", + "why": "⭐ **가장 위험한 자리** — 건설업 14 줄·제조업 16 줄에 일 노임 칸이 없다. 건설업 쪽은 `reliability` 가 `**`(미조사)인 줄이라 **값이 없는 것이 제값**이다. 되돌리기 null 과 구별이 안 된다." + }, + { + "table": "labor", + "column": "reliability · source_flag · variation_coefficient", + "rows": 221, + "shape": "칸이 아예 없음", + "why": "기호가 없는 줄 = 정상 공표라는 뜻이다. 빈 칸 자체가 값이다." + }, + { + "table": "machine", + "column": "fuel_liters_per_hour · misc_material_percent · operator_person_days", + "rows": 12, + "shape": "**명시 null**", + "why": "운전경비 파생본이 원문에서 못 읽은 칸을 `null` 로 적어 두었다 — 0 으로 안 때운 자리다. 다섯 가운데 **진짜 `null` 이 든 유일한 자료**다." + }, + { + "table": "machine", + "column": "economic_life_hours 외 손료계수 8 열", + "rows": 22, + "shape": "칸이 아예 없음", + "why": "손료계수는 있는데 그 속을 이루는 계수·내용시간이 원문에 없는 줄이다." + }, + { + "table": "machine", + "column": "specification", + "rows": 255, + "shape": "칸이 아예 없음", + "why": "규격이 없는 기종(취득가 230 · 연료 25). 규격 없음이 제값이라 빈 칸으로 둔다." + }, + { + "table": "material", + "column": "specification", + "rows": 2, + "shape": "빈 문자열", + "why": "null 이 아니라 `\"\"` 다. 되돌리기 null 과는 안 겹치나 **빈 칸 판정이 두 가지**가 된다." + } + ], + "rule": "⚠ 되돌리기를 `null` 로 보내면 위 자리에서 뜻이 겹친다. 겹침을 푸는 길은 둘 — ㉠ 되돌리기를 값이 아니라 **짓**으로 보낸다(덮개에서 그 열을 지움) · ㉡ 되돌리기 낱말을 따로 둔다(`\"@revert\"` 같은). 어느 쪽이든 **원본의 `null`·빈 칸은 그대로 살려야** 한다 — 「미조사」가 「값 지움」으로 바뀌면 안 된다. ⚠ 자재 `specification` 은 `null` 이 아니라 빈 문자열이라 빈 칸 판정이 두 가지다." + }, "value_kinds": { "value": { "name_ko": "값", @@ -93,6 +911,8 @@ } }, "counts": { + "merged_tables": 5, + "merged_columns": 62, "files": 34, "tables": 91, "columns": 455, diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 26ed63b0..671704e0 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -447,3 +447,94 @@ def test_미결은_미판정으로_표시된다(labels): column = labels["synthetic_tables"]["@values"]["columns"][3] assert column["key"] == "@undecided" assert column["name_ko"] == "미판정" + + +MERGED_KEYS = ("labor", "material", "oil", "rates", "machine") + + +def test_기초단가_다섯이_다_있다(labels): + merged = {m["key"]: m for m in labels["merged_tables"]} + assert tuple(merged) == MERGED_KEYS + for entry in merged.values(): + assert entry["name_ko"].strip() + assert entry["summary"].strip() + assert entry["columns"], entry["key"] + assert entry["sources"], entry["key"] + + +def test_합친_표의_열마다_이름과_고칠수있나가_있다(labels): + bad = [] + for entry in labels["merged_tables"]: + for column in entry["columns"]: + if not column["name_ko"].strip(): + bad.append(f"{entry['key']}/{column['key']} — 이름 없음") + if not isinstance(column.get("editable"), bool): + bad.append(f"{entry['key']}/{column['key']} — editable 없음") + assert not bad, bad + + +def test_계산값은_기계에만_있고_못_고친다(labels): + """⭐ 밑값만 고칠 수 있다 — 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없다.""" + derived = { + entry["key"]: [c["key"] for c in entry["columns"] if not c["editable"]] + for entry in labels["merged_tables"] + } + assert derived["machine"] == [ + "hourly_loss_krw", + "hourly_fuel_krw", + "hourly_operator_krw", + "hourly_total_krw", + ] + for key in ("labor", "material", "oil", "rates"): + assert derived[key] == [], f"{key} 에 계산값이 생겼다" + + +def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels): + chain = labels["machine_cost_chain"] + assert len(chain["steps"]) == 5 + for step in chain["steps"]: + assert step["step"].strip() and step["formula"].strip() + assert "밑값" in chain["editable_rule"] and "계산값" in chain["editable_rule"] + # 기계는 유가·노임 표를 문다 — 갱신 차례가 여기서 갈린다. + assert "유가" in chain["cross_table"] and "노임" in chain["cross_table"] + + +def test_열_맞대기_어긋남이_네_갈래로_적혀_있다(labels): + findings = labels["merge_findings"] + assert set(findings) == { + "renamed_same_column", + "one_side_only", + "unit_or_axis_mismatch", + "collision", + } + assert all(findings[k] for k in findings) + + +def test_줄_열쇠가_다섯_다_판정돼_있다(labels): + """덮개가 원본 줄을 붙드는 끈 — 없는 자료는 「없음」으로 내고 지어내지 않는다.""" + keys = {r["table"]: r for r in labels["row_keys"]} + assert set(keys) == set(MERGED_KEYS) + for entry in keys.values(): + assert entry["verdict"].strip() and entry["key"] and entry["checked"].strip() + assert keys["material"]["key"] == ["item_code"] + assert keys["machine"]["key"] == ["machine_code"] + # 한 칸으로는 안 되는 둘 — 묶어야 한다. + assert len(keys["labor"]["key"]) > 1 + assert keys["oil"]["verdict"].startswith("없음") + assert keys["rates"]["verdict"].startswith("없음") + + +def test_null_이_제값인_열이_적혀_있다(labels): + """되돌리기 null 과 원본의 「값 없음」이 겹치는 자리 — 겹치면 미조사가 값 지움으로 바뀐다.""" + block = labels["null_is_real"] + assert block["columns"] + for row in block["columns"]: + assert row["table"] in MERGED_KEYS + assert row["column"].strip() and row["why"].strip() + assert isinstance(row["rows"], int) + # 가장 위험한 자리 — 노임 일 노임이 없는 줄(미조사) + wage = [ + r for r in block["columns"] if r["table"] == "labor" and "daily_wage_krw" in r["column"] + ] + assert wage and wage[0]["rows"] == 30 + assert "되돌리기" in block["rule"] From 81a1c013d2f86fd6880f88bf624ecdc090129b9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 21:55:33 +0900 Subject: [PATCH 16/48] =?UTF-8?q?feat(z01):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=EC=9D=84=20B03~B09=20=EB=8F=84=ED=82=B9=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=96=91=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C(=EC=83=81=EC=9E=90=20=EB=91=98=20=E2=80=94?= =?UTF-8?q?=20=EA=B8=B0=EC=B4=88=EB=8B=A8=EA=B0=80=20=C2=B7=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=ED=8A=B8=EB=A6=AC)=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=8B=A8=EA=B0=80=20=EB=8B=A4=EC=84=AF(=EB=85=B8?= =?UTF-8?q?=EC=9E=84=C2=B7=EA=B8=B0=EA=B3=84=C2=B7=EC=9E=90=EC=9E=AC=C2=B7?= =?UTF-8?q?=EC=9C=A0=EA=B0=80=C2=B7=EC=9A=94=EC=9C=A8)=20=ED=91=9C=20+=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=A0=EC=B9=98=EA=B8=B0(editable=20=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EB=AA=BB=20=EA=B3=A0=EC=B9=98=EB=8A=94=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=80=20=EA=B9=8C=EB=8B=AD=C2=B7=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0)=20=C2=B7=20=EB=8D=AE=EA=B0=9C=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=80=20=EC=84=9C=EB=B2=84=20state=20=EB=A7=8C(=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=B0=94=EB=80=9C=C2=B7=EC=A3=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C=EC=9D=80=20=EC=A4=84=EC=A7=B8=20=EB=98=90?= =?UTF-8?q?=EB=A0=B7=EC=9D=B4=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=EA=B0=92)=20=C2=B7=20=EC=9A=94=EC=9C=A8?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EA=B2=83?= =?UTF-8?q?=20=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0(=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC)=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EC=97=90=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8B=A8=EA=B3=84=C2=B7=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EB=81=84=EA=B8=B0=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 83 +++-- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 58 ++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 271 ++-------------- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 339 +++++++++++++++++++++ Z01_MasterData/Z01_MasterData_UI_Side.ts | 177 +++++++++++ Z01_MasterData/Z01_MasterData_UI_Style.css | 174 ++++++++--- resources/tester/test_z01_master_cells.py | 66 +++- ui_template/ui_template_locale_b3.ts | 50 ++- ui_template/ui_template_overlay.ts | 36 +-- 9 files changed, 929 insertions(+), 325 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_UI_Rows.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_Side.ts diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index d2113c51..78735084 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn } from "./Z01_MasterData_UI_Cells"; +import type { MasterColumn, OverrideState, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -32,36 +32,83 @@ export interface MasterGroup { files: MasterFile[]; } -export interface MasterRows { +/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */ +export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; } -async function request(path: string): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); +export interface RowsQuery { + page: number; + size: number; + q: string; +} + +/** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */ +export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; +export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number]; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); const data = (await response.json()) as { detail?: string } & T; if (!response.ok) throw new Error(data.detail ?? "Request failed"); return data; } +const pageParams = (query: RowsQuery): Record => ({ + page: String(query.page), + size: String(query.size), + q: query.q, +}); + +export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise { + const params = new URLSearchParams(pageParams(query)); + return request(`/master-data/base-prices/${kind}?${params}`); +} + +export interface OverrideItem { + kind: BasePriceKind; + row_id: string; + row_label: string; + column: string; + column_label: string; + original: unknown; + current?: unknown; + value: unknown; + state: OverrideState; +} + +/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */ +export async function fetchOverrides(): Promise { + return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items; +} + +/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ +export function saveBasePrice( + kind: BasePriceKind, + rowId: string, + values: Record, +): Promise> { + return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, { + method: "PUT", + body: JSON.stringify({ values }), + }); +} + export async function fetchMasterTree(): Promise { return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups; } -export function fetchMasterRows(query: { - file: string; - table: string; - page: number; - size: number; - q: string; -}): Promise { - const params = new URLSearchParams({ - file: query.file, - table: query.table, - page: String(query.page), - size: String(query.size), - q: query.q, - }); +export function fetchMasterRows( + file: string, + table: string, + query: RowsQuery, +): Promise { + const params = new URLSearchParams({ file, table, ...pageParams(query) }); return request(`/master-data/rows?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7b170044..09a9b098 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -40,6 +40,64 @@ export function rowCells(row: Record, columns: MasterColumn[]): ); } +/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ +export interface TableMeta { + editable?: string[]; + /** 열key → 왜 못 고치나 */ + locked?: Record; + /** 열key → 식 자체 */ + formula?: Record; +} + +/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */ +export type OverrideState = "edited" | "source_changed" | "orphan"; + +export interface CellInfo { + editable: boolean; + reason: string | null; + overridden: boolean; + /** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */ + original?: unknown; + /** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */ + state: OverrideState | "missing" | null; + /** source_changed 칸만 — 지금 원본 값 */ + current?: unknown; + rule: string | null; + /** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */ + filled: string | null; +} + +/** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */ +export function cellInfo(meta: TableMeta, row: Record, key: string): CellInfo { + const overrides = row["@overrides"] as + Record | undefined; + const filled = row["@formula"] as Record | undefined; + const override = overrides?.[key]; + // 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인). + const state = override ? (override.state ?? "missing") : null; + return { + editable: meta.editable?.includes(key) ?? false, + reason: meta.locked?.[key] ?? null, + overridden: override !== undefined, + ...(override ? { original: override.original } : {}), + state, + ...(state === "source_changed" ? { current: override?.current } : {}), + rule: meta.formula?.[key] ?? null, + filled: filled?.[key] ?? null, + }; +} + +const NUMBER_TEXT = /^-?[\d,]+(\.\d+)?$/; + +/** 고친 글자를 보낼 값으로 — 수 칸은 쉼표를 떼 수로, 못 읽으면 undefined(저장 안 함). */ +export function parseCellInput(text: string, original: unknown): unknown { + const trimmed = text.trim(); + const numeric = typeof original === "number" || (original == null && NUMBER_TEXT.test(trimmed)); + if (!numeric) return text; + if (!NUMBER_TEXT.test(trimmed)) return undefined; + return Number(trimmed.replaceAll(",", "")); +} + export function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index df8c6f5b..d077e77e 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -1,46 +1,23 @@ /* ============================================================================= * Z01_MasterData_UI_Page.ts - * 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색) + * 마스터 데이터 — B03~B09 와 같은 틀: 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 * - * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터 - * (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립). + * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 워크플로 밖 화면이라 진행단계 + * 패널·프로젝트 이름은 안 세움 (2026-09-15 브레인 Z01 — 좌측 패널 양식 갈음 · 기초단가 칸). * ========================================================================== */ +import "@ui/ui_template_workflow_layout.css"; import { ROUTES } from "@config/config_frontend"; -import { - createButton, - createInputField, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; +import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { - fetchMasterRows, - fetchMasterTree, - type MasterFile, - type MasterGroup, - type MasterRows, - type MasterTable, - VALUES_TABLE_ID, -} from "./Z01_MasterData_Api_Fetch"; -import { - clampPage, - columnTitle, - latestOnly, - pageCount, - rowCells, - shownColumns, -} from "./Z01_MasterData_UI_Cells"; +import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import { buildRowsView } from "./Z01_MasterData_UI_Rows"; +import { buildSidePanel } from "./Z01_MasterData_UI_Side"; import "./Z01_MasterData_UI_Style.css"; -const PAGE_SIZE = 50; -const SEARCH_DELAY_MS = 300; - export async function renderZ01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { @@ -63,223 +40,17 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { function buildPage(groups: MasterGroup[]): HTMLElement { const view = buildRowsView(); - const tree = buildTree(groups, view.pick); - const content = el("div", { className: "z01-master__content", children: [tree, view.root] }); - return createGeneralLayout({ - pageClass: "z01-master", + const layout = el("div", { className: "ui-workflow-layout z01-master" }); + const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); + const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - subtitle: L("Z01_MasterData_Subtitle"), - content, - }).root; -} - -function buildTree( - groups: MasterGroup[], - pick: (file: MasterFile, table: MasterTable) => void, -): HTMLElement { - const nav = el("nav", { className: "z01-master__tree" }); - for (const group of groups) { - const groupBox = folder("z01-master__group", group.label, String(group.files.length)); - groupBox.open = true; - // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). - if (group.key === "byproduct") { - groupBox.append( - el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), - ); - } - for (const file of group.files) { - const fileBox = folder( - "z01-master__file", - file.label, - file.label === file.key ? "" : file.key, - ); - // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). - const ordered = [...file.tables].sort( - (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), - ); - for (const table of ordered) { - const button = el("button", { - className: "z01-master__table", - attrs: { type: "button", title: table.key }, - children: [ - el("span", { text: table.label }), - el("span", { - className: "z01-master__key", - text: table.row_count.toLocaleString("ko-KR"), - }), - ], - }); - button.addEventListener("click", () => { - nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active"); - button.classList.add("is-active"); - pick(file, table); - }); - fileBox.append(button); - } - groupBox.append(fileBox); - } - nav.append(groupBox); - } - return nav; -} - -/** 접는 칸 — 브라우저 기본
로 둠. */ -function folder(className: string, label: string, aside: string): HTMLDetailsElement { - const summary = el("summary", { - children: [ - el("span", { text: label }), - el("span", { className: "z01-master__key", text: aside }), - ], - }); - return el("details", { className, children: [summary] }); -} - -interface RowsView { - root: HTMLElement; - pick: (file: MasterFile, table: MasterTable) => void; -} - -function buildRowsView(): RowsView { - const state = { - file: null as MasterFile | null, - table: null as MasterTable | null, - page: 1, - q: "", - }; - let showHidden = false; - let last: MasterRows | null = null; - 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" }); - const search = createInputField({ - type: "search", - placeholder: L("Z01_MasterData_Search"), - onInput: (value) => { - window.clearTimeout(searchTimer); - searchTimer = window.setTimeout(() => { - state.q = value.trim(); - state.page = 1; - void load(); - }, SEARCH_DELAY_MS); - }, - }); - const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); - hiddenToggle.addEventListener("change", () => { - showHidden = hiddenToggle.checked; - draw(); - }); - const hiddenLabel = el("label", { - className: "z01-master__check", - 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" }); - // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). - const pagerButton = (key: Parameters[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: [first, prev, pageInput, pageInfo, next, end], - }); - const root = el("section", { - className: "z01-master__panel", - children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], - }); - toolbar.hidden = true; - pager.hidden = true; - - 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 { - if (!state.file || !state.table) return; - grid.classList.add("is-loading"); - let data: MasterRows | undefined; - try { - data = await fetchLatest({ - file: state.file.id, - table: state.table.id, - page: state.page, - size: PAGE_SIZE, - q: state.q, - }); - } catch (error) { - 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 { - if (!last) return; - const columns = shownColumns(last.columns, showHidden); - const headRow = el("tr"); - for (const column of columns) { - const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); - if (column.label !== column.key) { - th.append(el("span", { className: "z01-master__key", text: column.key })); - } - headRow.append(th); - } - const body = el("tbody"); - for (const row of last.rows) { - const tr = el("tr"); - for (const text of rowCells(row, columns)) { - tr.append(el("td", { text, attrs: { title: text } })); - } - body.append(tr); - } - grid.replaceChildren( - last.rows.length - ? el("table", { - className: "z01-master__grid", - children: [el("thead", { children: [headRow] }), body], - }) - : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), - ); - const pages = pageCount(last.total, PAGE_SIZE); - 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 { - state.file = file; - state.table = table; - state.page = 1; - title.textContent = `${file.label} › ${table.label}`; - titleKey.textContent = `${file.key} / ${table.key}`; - toolbar.hidden = false; - pager.hidden = false; - last = null; - grid.replaceChildren(); - void load(); - } - - return { root, pick }; + optionsContent: buildSidePanel(groups, view), + showProjectName: false, + onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), + }); + layout.append( + el("div", { className: "ui-workflow-layout__body", children: [main] }), + overlays.root, + ); + return layout; } diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts new file mode 100644 index 00000000..75e6cca4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * Z01_MasterData_UI_Rows.ts + * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 + * + * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. + * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 + * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; +import { + cellInfo, + cellText, + clampPage, + columnTitle, + latestOnly, + pageCount, + parseCellInput, + rowCells, + shownColumns, + type CellInfo, + type MasterColumn, +} from "./Z01_MasterData_UI_Cells"; + +const PAGE_SIZE = 50; +const SEARCH_DELAY_MS = 300; + +type Row = Record; + +export interface RowsSource { + title: string; + key: string; + /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ + notice?: string; + load: (query: RowsQuery) => Promise; + /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ + save?: (rowId: string, values: Row) => Promise; +} + +export interface RowsView { + root: HTMLElement; + show: (source: RowsSource) => void; +} + +/** 사전 문구의 {value} 자리에 값을 넣음. */ +function fill(key: Parameters[0], value: string): string { + return L(key).replace("{value}", value); +} + +/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ +function stateText(info: CellInfo, column: MasterColumn): string { + const original = cellText(info.original, column.unit); + switch (info.state) { + case "source_changed": + return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + case "orphan": + return L("Z01_MasterData_State_Orphan"); + case "missing": + return L("Z01_MasterData_State_Missing"); + default: + return fill("Z01_MasterData_State_Edited", original); + } +} + +export function buildRowsView(): RowsView { + let source: RowsSource | null = null; + const state = { page: 1, q: "" }; + let showHidden = false; + let last: MasterRows | null = null; + let selected: { index: number; key: string } | null = null; + let searchTimer = 0; + const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); + + const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); + const titleKey = el("span", { className: "z01-master__key" }); + const search = createInputField({ + type: "search", + placeholder: L("Z01_MasterData_Search"), + onInput: (value) => { + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + state.q = value.trim(); + state.page = 1; + void load(); + }, SEARCH_DELAY_MS); + }, + }); + const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); + hiddenToggle.addEventListener("change", () => { + showHidden = hiddenToggle.checked; + draw(); + }); + const hiddenLabel = el("label", { + className: "z01-master__check", + children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], + }); + const notice = el("p", { className: "z01-master__notice" }); + const detail = el("div", { className: "z01-master__detail" }); + 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" }); + // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). + const pagerButton = (key: Parameters[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: [first, prev, pageInput, pageInfo, next, end], + }); + const root = el("section", { + className: "z01-master__panel", + children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager], + }); + toolbar.hidden = true; + pager.hidden = true; + detail.hidden = true; + notice.hidden = true; + + 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 { + if (!source) return; + grid.classList.add("is-loading"); + let data: MasterRows | undefined; + try { + data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q }); + } catch (error) { + 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; + selected = null; + detail.hidden = true; + draw(); + } + + function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { + const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); + const marks: [string, string][] = []; + if (source?.save && meta.editable?.includes(column.key)) { + marks.push(["✎", L("Z01_MasterData_Edit_Can")]); + } + const reason = meta.locked?.[column.key]; + if (reason) marks.push(["🔒", reason]); + const rule = meta.formula?.[column.key]; + if (rule) marks.push(["ƒ", rule]); + for (const [mark, tip] of marks) { + th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } })); + } + if (column.label !== column.key) { + th.append(el("span", { className: "z01-master__key", text: column.key })); + } + return th; + } + + function draw(): void { + if (!last) return; + const meta = last; + const columns = shownColumns(meta.columns, showHidden); + const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); + const body = el("tbody"); + meta.rows.forEach((row, index) => { + const texts = rowCells(row, columns); + const tr = el("tr"); + columns.forEach((column, i) => { + const info = cellInfo(meta, row, column.key); + const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); + if (info.state) { + // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). + td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`); + if (info.state !== "edited") { + td.textContent = `⚠ ${texts[i]}`; + tr.classList.add("has-warning"); + } + td.title = stateText(info, column); + } + const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; + if (canEdit) td.classList.add("is-editable"); + if (selected?.index === index && selected.key === column.key) + td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + selectCell(index, column); + td.classList.add("is-selected"); + if (canEdit) startEdit(td, index, column); + }); + tr.append(td); + }); + body.append(tr); + }); + grid.replaceChildren( + meta.rows.length + ? el("table", { + className: "z01-master__grid", + children: [el("thead", { children: [headRow] }), body], + }) + : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), + ); + const pages = pageCount(meta.total, PAGE_SIZE); + pageInput.value = String(state.page); + pageInput.max = String(pages); + pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; + first.disabled = prev.disabled = state.page <= 1; + next.disabled = end.disabled = state.page >= pages; + } + + /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ + function selectCell(index: number, column: MasterColumn): void { + if (!last) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); + selected = { index, key: column.key }; + const row = last.rows[index]; + const info = cellInfo(last, row, column.key); + const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; + const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (source?.save) { + lines.push( + line( + info.editable + ? L("Z01_MasterData_Edit_Can") + : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, + ), + ); + } + if (info.state) { + const stateLine = line(stateText(info, column)); + if (info.state !== "edited") stateLine.classList.add("is-warning"); + lines.push(stateLine); + // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). + const target = + info.state === "source_changed" + ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) + : info.state === "orphan" + ? null + : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); + if (target && source?.save) { + lines.push( + createButton({ + label: target, + variant: "ghost", + onClick: () => void commit(index, column, null), + }), + ); + } + } + if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); + if (info.filled) lines.push(line(info.filled)); + detail.replaceChildren(...lines); + detail.hidden = false; + } + + function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { + if (!last) return; + const current = last.rows[index][column.key]; + const input = el("input", { className: "ui-input z01-master__cell-input" }); + input.value = current == null ? "" : String(current); + td.replaceChildren(input); + input.focus(); + input.select(); + let finished = false; + const finish = (save: boolean): void => { + if (finished) return; + finished = true; + const value = save ? parseCellInput(input.value, current) : current; + if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); + if (value === undefined || value === current) { + draw(); + return; + } + void commit(index, column, value); + }; + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") finish(true); + if (event.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); + } + + /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ + async function commit(index: number, column: MasterColumn, value: unknown): Promise { + const from = source; + const table = last; + const rowId = table?.rows[index]["@id"]; + if (!from?.save || !table || rowId == null) return; + try { + const updated = await from.save(String(rowId), { [column.key]: value }); + if (from !== source || table !== last) return; + table.rows[index] = updated; + draw(); + selectCell(index, column); + showToast( + L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), + "success", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + draw(); + } + } + + function show(picked: RowsSource): void { + source = picked; + state.page = 1; + title.textContent = picked.title; + titleKey.textContent = picked.key; + notice.textContent = picked.notice ?? ""; + notice.hidden = !picked.notice; + toolbar.hidden = false; + pager.hidden = false; + detail.hidden = true; + last = null; + selected = null; + grid.replaceChildren(); + void load(); + } + + return { root, show }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts new file mode 100644 index 00000000..b47e4fc4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * Z01_MasterData_UI_Side.ts + * 좌측 패널 — B03~B09 와 같은 도킹 사이드바 안에 접히는 상자 둘(기초단가 · 마스터 트리) + * + * 상자 겉모습·접기는 공용(`ui-collapsible ui-sidebar-section`) · 누른 단추 하나만 강조 + * (두 상자를 가로질러 한 개) (2026-09-15 브레인 — 좌측 패널 양식 갈음 · 기초단가 칸). + * ========================================================================== */ + +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + BASE_PRICE_KINDS, + fetchBasePrices, + fetchMasterRows, + fetchOverrides, + type MasterGroup, + type MasterRows, + type RowsQuery, + saveBasePrice, + VALUES_TABLE_ID, +} from "./Z01_MasterData_Api_Fetch"; +import type { RowsView } from "./Z01_MasterData_UI_Rows"; + +export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { + const panel = el("div", { className: "z01-master__side" }); + const activate = (button: HTMLElement): void => { + panel.querySelector(".is-active")?.classList.remove("is-active"); + button.classList.add("is-active"); + }; + + const baseTitle = L("Z01_MasterData_BasePrices"); + const baseGrid = el("div", { className: "z01-master__base-grid" }); + for (const kind of BASE_PRICE_KINDS) { + const label = L(`Z01_MasterData_Base_${kind}` as const); + const button = el("button", { + className: "z01-master__base", + attrs: { type: "button" }, + text: label, + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${baseTitle} › ${label}`, + key: `base-prices/${kind}`, + // 요율은 구간 줄에 코드가 없어 구간 이름으로 이음 — 법이 바뀌면 고친 값이 주인을 잃기 쉬움. + notice: kind === "rate" ? L("Z01_MasterData_Rate_Notice") : undefined, + load: (query) => fetchBasePrices(kind, query), + save: (rowId, values) => saveBasePrice(kind, rowId, values), + }); + }); + baseGrid.append(button); + } + const overridesButton = el("button", { + className: "z01-master__base z01-master__base--wide", + attrs: { type: "button" }, + text: L("Z01_MasterData_Overrides"), + }); + overridesButton.addEventListener("click", () => { + activate(overridesButton); + view.show({ + title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, + key: "overrides", + load: loadOverrides, + }); + }); + baseGrid.append(overridesButton); + + const tree = el("nav", { className: "z01-master__tree" }); + for (const group of groups) { + const groupBox = folder("z01-master__group", group.label, String(group.files.length)); + groupBox.open = true; + // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). + if (group.key === "byproduct") { + groupBox.append( + el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), + ); + } + for (const file of group.files) { + const fileBox = folder( + "z01-master__file", + file.label, + file.label === file.key ? "" : file.key, + ); + // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). + const ordered = [...file.tables].sort( + (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), + ); + for (const table of ordered) { + const button = el("button", { + className: "z01-master__table", + attrs: { type: "button", title: table.key }, + children: [ + el("span", { text: table.label }), + el("span", { + className: "z01-master__key", + text: table.row_count.toLocaleString("ko-KR"), + }), + ], + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${file.label} › ${table.label}`, + key: `${file.key} / ${table.key}`, + load: (query) => fetchMasterRows(file.id, table.id, query), + }); + }); + fileBox.append(button); + } + groupBox.append(fileBox); + } + tree.append(groupBox); + } + + panel.append(section(baseTitle, baseGrid), section(L("Z01_MasterData_Tree"), tree)); + attachCollapsible(panel); + return panel; +} + +const STATE_LABEL = { + source_changed: "Z01_MasterData_List_SourceChanged", + orphan: "Z01_MasterData_List_Orphan", + edited: "Z01_MasterData_List_Edited", +} as const; + +/** + * 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름. + * 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). + */ +async function loadOverrides(query: RowsQuery): Promise { + const needle = query.q.toLowerCase(); + const items = (await fetchOverrides()).filter( + (item) => !needle || JSON.stringify(item).toLowerCase().includes(needle), + ); + const start = (query.page - 1) * query.size; + const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); + return { + columns: [ + column("state", "Z01_MasterData_List_State"), + column("kind", "Z01_MasterData_BasePrices"), + column("row_label", "Z01_MasterData_List_Row"), + column("column_label", "Z01_MasterData_List_Column"), + column("value", "Z01_MasterData_List_Value"), + column("original", "Z01_MasterData_List_Original"), + column("current", "Z01_MasterData_List_Current"), + ], + rows: items.slice(start, start + query.size).map((item) => ({ + ...item, + // state 가 안 오면 때우지 않고 드러냄(서버 버그). + state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), + kind: L(`Z01_MasterData_Base_${item.kind}` as const), + })), + total: items.length, + }; +} + +function section(title: string, body: HTMLElement): HTMLElement { + return el("section", { + className: "z01-master__section ui-collapsible ui-sidebar-section", + children: [ + el("p", { className: "z01-master__section-title ui-collapsible__title", text: title }), + body, + ], + }); +} + +/** 트리 접는 칸 — 브라우저 기본
로 둠. */ +function folder(className: string, label: string, aside: string): HTMLDetailsElement { + const summary = el("summary", { + children: [ + el("span", { text: label }), + el("span", { className: "z01-master__key", text: aside }), + ], + }); + return el("details", { className, children: [summary] }); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index a0c0a9aa..824dbfff 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -1,24 +1,66 @@ -/* Z01 마스터 데이터 — 좌 트리 / 우 표 */ +/* Z01 마스터 데이터 — 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 */ .z01-master [hidden] { display: none !important; } -.z01-master__content { +/* ── 좌측 패널: B08 과 같은 상자 틀(겉선·접기는 공용 ui-sidebar-section · ui-collapsible) ── */ +.z01-master__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); +} + +.z01-master__section { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} + +.z01-master__section-title { + margin: 0 0 2px; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; +} + +.z01-master__base-grid { display: grid; - grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); - gap: var(--spacing-24); - align-items: start; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4); +} + +.z01-master__base { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + cursor: pointer; +} + +.z01-master__base--wide { + grid-column: 1 / -1; +} + +.z01-master__base:hover, +.z01-master__table:hover { + background: var(--color-paper); +} + +.z01-master__base.is-active, +.z01-master__table.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); } .z01-master__tree { - position: sticky; - top: var(--spacing-16); - max-height: calc(100vh - 160px); - overflow-y: auto; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); font-size: var(--text-body-sm); } @@ -26,7 +68,7 @@ display: flex; justify-content: space-between; gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); + padding: var(--spacing-4) var(--spacing-4); cursor: pointer; color: var(--color-text-body); } @@ -68,20 +110,13 @@ cursor: pointer; } -.z01-master__table:hover { - background: var(--color-surface-raised); -} - -.z01-master__table.is-active { - background: var(--color-mist-violet); - color: var(--color-accent); -} - +/* ── 우측 표 ── */ .z01-master__panel { display: flex; flex-direction: column; - gap: var(--spacing-16); + gap: var(--spacing-12); min-width: 0; + padding: var(--spacing-16) var(--spacing-24); } .z01-master__title { @@ -90,6 +125,16 @@ font-size: var(--text-body); } +/* 요율 알림 — 막지 않고 알리기만 */ +.z01-master__notice { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-paper); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + .z01-master__toolbar { display: flex; flex-wrap: wrap; @@ -105,8 +150,30 @@ font-size: var(--text-body-sm); } +/* 칸 설명 줄 — 고칠 수 있나 · 까닭 · 원래 값 · 산출근거 */ +.z01-master__detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + font-size: var(--text-body-sm); +} + +.z01-master__detail-line { + color: var(--color-text-body); +} + +.z01-master__detail-line.is-warning { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + .z01-master__grid-wrap { - max-height: calc(100vh - 300px); + max-height: calc(100vh - 320px); overflow: auto; border: 1px solid var(--color-border); border-radius: var(--radius-cards); @@ -146,6 +213,52 @@ margin-left: 0; } +.z01-master__mark { + margin-left: var(--spacing-4); + cursor: help; +} + +.z01-master__grid td.is-editable { + cursor: text; + text-decoration: underline dotted var(--color-text-muted); +} + +.z01-master__grid td.is-selected { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +/* 고쳐짐 — 덮개 값을 쓰는 칸 */ +.z01-master__grid td.is-overridden { + background: var(--color-mist-violet); + color: var(--color-accent); + font-weight: var(--font-weight-medium); +} + +/* 원본 바뀜 · 주인 없음 · 판정 없음 — 줄째 또렷이(흐리면 옛 값이 조용히 쓰임) */ +.z01-master__grid tr.has-warning td { + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas)); +} + +.z01-master__grid tr.has-warning td:first-child { + box-shadow: inset 4px 0 0 var(--color-danger); +} + +/* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */ +.z01-master__grid tr td.is-source-changed, +.z01-master__grid tr td.is-orphan, +.z01-master__grid tr td.is-missing { + background: color-mix(in srgb, var(--color-danger) 22%, var(--color-canvas)); + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +.z01-master__cell-input { + width: 100%; + min-width: 6em; + padding: 2px var(--spacing-4); +} + .z01-master__pager { display: flex; align-items: center; @@ -159,14 +272,3 @@ width: 6em; text-align: right; } - -@media (max-width: 860px) { - .z01-master__content { - display: block; - } - - .z01-master__tree { - position: static; - margin-bottom: var(--spacing-24); - } -} diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index d96a9299..d031a4de 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -24,9 +24,34 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; import { - cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns, + cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, + shownColumns, } from "./Z01_MasterData_UI_Cells.js"; +// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인) +const meta = { + editable: ["daily_wage_krw"], + locked: { hourly_krw: "계산으로 나옴" }, + formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" }, +}; +const machineRow = { + "@id": "M-001", + daily_wage_krw: 300000, + hourly_krw: 12340, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000 } }, + "@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" }, +}; +// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈) +const withState = (state, current) => ({ + "@id": "M-002", + daily_wage_krw: 300000, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } }, +}); +// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited +const changedRow = withState("source_changed", 250000); +const sameRow = withState("edited", 270000); +const orphanRow = withState("orphan", undefined); + const wait = (ms, value, fail) => new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms)); const fetchLatest = latestOnly(wait); @@ -62,6 +87,21 @@ writeFileSync(process.argv[2], JSON.stringify({ race: race.map((v) => v ?? null), raceError: raceError.map((v) => v ?? null), latestError, + info: [ + cellInfo(meta, machineRow, "daily_wage_krw"), + cellInfo(meta, machineRow, "hourly_krw"), + cellInfo({}, { name: "x" }, "name"), + cellInfo(meta, changedRow, "daily_wage_krw"), + cellInfo(meta, sameRow, "daily_wage_krw"), + cellInfo(meta, orphanRow, "daily_wage_krw"), + ], + parsed: [ + parseCellInput("12,340", 100), + parseCellInput("abc", 100), + parseCellInput("", 100), + parseCellInput("보통인부2", "보통인부"), + parseCellInput("3.5", null), + ].map((v) => (v === undefined ? "invalid" : v)), })); """ @@ -109,3 +149,27 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["race"] == [None, "new"] assert got["raceError"] == [None, "new"] assert got["latestError"] == "thrown boom" + # 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식 + wage, hourly, plain, changed, same, orphan = got["info"] + # state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그) + assert wage == { + "editable": True, + "reason": None, + "overridden": True, + "original": 250000, + "state": "missing", + "rule": None, + "filled": None, + } + # 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인) + assert changed["state"] == "source_changed" and changed["current"] == 250000 + assert same["state"] == "edited" and "current" not in same + assert orphan["state"] == "orphan" + assert hourly["state"] is None and plain["state"] is None + assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴" + assert hourly["overridden"] is False and "original" not in hourly + assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무" + assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000" + assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None + # 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수 + assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 8194f52d..a170ecde 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -140,10 +140,54 @@ export const ui_locales_b3 = { B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"], B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], Z01_MasterData_Title: ["마스터 데이터", "Master Data"], - Z01_MasterData_Subtitle: [ - "로직·기초값을 표로 봄 — 읽기 전용", - "Browse logic and base values — read only", + Z01_MasterData_Tree: ["마스터 트리", "Master tree"], + Z01_MasterData_BasePrices: ["기초단가", "Base prices"], + Z01_MasterData_Base_labor: ["노임단가", "Labor"], + Z01_MasterData_Base_machine: ["기계단가", "Machine"], + Z01_MasterData_Base_material: ["자재단가", "Material"], + Z01_MasterData_Base_oil: ["유가", "Fuel"], + Z01_MasterData_Base_rate: ["요율", "Rates"], + Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], + Z01_MasterData_Rate_Notice: [ + "법이 정한 값 — 고치면 갱신 때 주인을 잃기 쉬움", + "Values set by law — edits may lose their row when the law is updated", ], + Z01_MasterData_Edit_Can: [ + "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", + "Editable — click, type, Enter to save (Esc cancels)", + ], + Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], + Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], + Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], + Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], + Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], + Z01_MasterData_Edit_Invalid: ["수로 못 읽음 — 저장 안 함", "Not a number — not saved"], + Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], + Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], + Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], + Z01_MasterData_Formula: ["산출근거", "Basis"], + Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], + Z01_MasterData_State_SourceChanged: [ + "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", + "⚠ Source changed after edit — edited value still used · new source {value}", + ], + Z01_MasterData_State_Orphan: [ + "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", + "⚠ Orphan — the row disappeared on update", + ], + Z01_MasterData_State_Missing: [ + "⚠ 서버가 판정(state)을 안 줌 — 버그", + "⚠ Server sent no state — bug", + ], + Z01_MasterData_List_State: ["상태", "State"], + Z01_MasterData_List_Row: ["줄", "Row"], + Z01_MasterData_List_Column: ["칸", "Column"], + Z01_MasterData_List_Value: ["고친 값", "Edited value"], + Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], + Z01_MasterData_List_Current: ["지금 원본", "Current source"], + Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], + Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], + Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 9d1dfccc..53cf2505 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; - progressContent: HTMLElement; + /** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */ + progressContent?: HTMLElement; optionsContent?: HTMLElement; showTitlePanel?: boolean; + /** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */ + showProjectName?: boolean; onOptionsOpenChange?: (isOpen: boolean) => void; } @@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo const root = document.createElement("div"); root.className = "ui-workflow-overlay"; - const progressBody = document.createElement("div"); - progressBody.append(options.progressContent); - - const progressPanel = createPanel( - "progress", - t("Workflow_Progress_Title"), - progressBody, - PROGRESS_OVERLAY_STATE_KEY, - ); let setTitleOpen = (_isOpen: boolean): void => {}; if (options.showTitlePanel !== false) { const titleBody = document.createElement("div"); @@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, - createProjectNameTag(), + options.showProjectName === false ? undefined : createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen; } - root.append(progressPanel.root); + let setProgressOpen = (_isOpen: boolean): void => {}; + if (options.progressContent) { + const progressBody = document.createElement("div"); + progressBody.append(options.progressContent); + const progressPanel = createPanel( + "progress", + t("Workflow_Progress_Title"), + progressBody, + PROGRESS_OVERLAY_STATE_KEY, + ); + root.append(progressPanel.root); + setProgressOpen = progressPanel.setOpen; + } - return { - root, - setTitleOpen, - setProgressOpen: progressPanel.setOpen, - }; + return { root, setTitleOpen, setProgressOpen }; } From 35632f8cb97ea43d98f62237b11565f23ee5d42f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:00:17 +0900 Subject: [PATCH 17/48] =?UTF-8?q?feat(z01):=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=ED=91=9C=20=E2=80=94=20=EC=96=B4=EB=94=94?= =?UTF-8?q?=EC=84=9C=20=EC=98=A4=EA=B3=A0=20=EC=96=BC=EB=A7=88=20=EB=A7=8C?= =?UTF-8?q?=EC=97=90=20=EB=B0=94=EB=80=8C=EB=8A=94=EC=A7=80=20=EC=97=AC?= =?UTF-8?q?=EB=8D=9F=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resources/data_master_sources/sources_2026-09-15.json` — 갈래·자료 이름·내는 곳·받는 자리· 주기·최신 공표일·우리 판·확인한 날·비고. **값은 안 담음** - ⭐ **건설 노임이 뒤처진 것으로 섬** — 우리 2026-01-01 · 최신 2026-09-01 · 주기가 1.1 / 9.1 임(7.1 로 알던 것이 틀렸음) · 받는 자리 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금] - **우리 판은 실제 파일에서 읽음** — 손으로 적으면 갱신하고 안 고쳐 또 뒤처짐. 파생본은 `derived_from.effective_date` 를 따라감(운전경비 = 2026-01-01 · 「판 모름」 아님) - 받는 자리·최신 공표일을 **모르는 칸은 비우고 사유를 닮** — 주소를 지어내지 않음(빈 칸 다섯) - 셈 — 뒤처짐 1 · 최신 3 · 모름 4(수시·일별이라 한 날로 안 정해지는 자재·유가 셋 포함) - 새 시험 `test_master_sources.py` 11건 — 표의 우리 판이 실제 파일과 어긋나거나 뒤처짐 판정이 뒤집히거나 모르는 칸에 사유가 없으면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../sources_2026-09-15.json | 207 ++++++++++++++++++ resources/tester/test_master_sources.py | 128 +++++++++++ 2 files changed, 335 insertions(+) create mode 100644 resources/data_master_sources/sources_2026-09-15.json create mode 100644 resources/tester/test_master_sources.py diff --git a/resources/data_master_sources/sources_2026-09-15.json b/resources/data_master_sources/sources_2026-09-15.json new file mode 100644 index 00000000..ec955a7e --- /dev/null +++ b/resources/data_master_sources/sources_2026-09-15.json @@ -0,0 +1,207 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_master_sources", + "effective_date": "2026-09-15", + "generated_at": "2026-09-15T21:59:23+09:00", + "note": "기초단가 자료가 **어디서 오고 얼마 만에 바뀌는지** 적은 표. 파일 안에는 제 판만 있고 「최신이 무엇인지·어디서 받는지」가 없어 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). 그 자리를 메운다.", + "policy": { + "values_untouched": "값을 담지 않는다. 자료가 어디서 오는지만 담는다.", + "our_edition_is_read": "`our_edition` 은 **실제 파일에서 읽는다**(`effective_date`, 파생본은 `derived_from.effective_date`). 손으로 적으면 갱신하고 안 고쳐 또 뒤처진다.", + "no_invented_address": "받는 자리는 **받은 것만** 적는다. 모르면 빈 칸으로 두고 사유를 단다 — 주소를 지어내지 않는다.", + "status_rule": "`latest_published` 가 `our_edition` 보다 뒤면 **뒤처짐**, 같으면 최신, 둘 가운데 하나라도 없으면 **모름**. 날마다 바뀌는 유가는 날짜 차이로 보여야 한다." + }, + "kinds": { + "labor": "노임", + "machine": "기계", + "material": "자재", + "oil": "유가", + "rate": "요율" + }, + "status": { + "behind": { + "name_ko": "뒤처짐", + "summary": "최신 공표일이 우리 판보다 뒤다 — 받아야 한다." + }, + "current": { + "name_ko": "최신", + "summary": "최신 공표일과 우리 판이 같다." + }, + "unknown": { + "name_ko": "모름", + "summary": "최신 공표일을 아직 못 잡았다 — 수시·일별이라 한 날로 안 정해지거나, 확인을 아직 안 했다." + } + }, + "counts": { + "sources": 8, + "by_status": { + "behind": 1, + "unknown": 4, + "current": 3 + } + }, + "sources": [ + { + "source_id": "labor_const", + "kind": "labor", + "kind_ko": "노임", + "name_ko": "건설업 시중노임단가", + "publisher": "대한건설협회", + "where_to_get": "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]", + "cycle": "해마다 두 번 — **1월 1일 · 9월 1일**", + "latest_published": "2026-09-01", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-01-01", + "our_edition_from": "effective_date", + "status": "behind", + "checked_on": "2026-09-15", + "note": "⚠ **뒤처졌다.** 우리 판이 2026-01-01 이라 9월 공표를 반 년 가까이 못 받았다. ⚠ 주기를 **7월 1일로 알고 있던 것이 틀렸다** — 1.1 / 9.1 이다. 노임은 일위대가 품값의 밑값이라 여기가 뒤처지면 **모든 호표가 옛값으로 선다**.", + "files": [ + "resources/data_cost_input_value/labor_const_2026-01-01.json" + ] + }, + { + "source_id": "labor_mfg", + "kind": "labor", + "kind_ko": "노임", + "name_ko": "중소제조업 직종별 임금", + "publisher": "중소기업중앙회", + "where_to_get": "", + "cycle": "반기", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-07-01", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다. 우리 판은 2026 상반기 조사(조사월 2026-03 · 공표 2026-06-30)다.", + "files": [ + "resources/data_cost_input_value/labor_mfg_2026-07-01.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "mach_base", + "kind": "machine", + "kind_ko": "기계", + "name_ko": "건설공사 표준품셈 제8장 건설기계", + "publisher": "국토교통부", + "where_to_get": "", + "cycle": "해마다", + "latest_published": "2026-01-01", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-01-01", + "our_edition_from": "effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "우리 판이 최신이다. 취득가·손료계수·연료소모량·조종원이 다 여기서 온다.", + "files": [ + "resources/data_cost_input_value/mach_base_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + }, + { + "source_id": "machine_operating", + "kind": "machine", + "kind_ko": "기계", + "name_ko": "건설기계 운전경비(품셈 8-4 파생본)", + "publisher": "국토교통부", + "where_to_get": "", + "cycle": "해마다 — 위 품셈을 따라간다", + "latest_published": "2026-01-01", + "latest_basis": "파생 원본(mach_base)의 판", + "our_edition": "2026-01-01", + "our_edition_from": "derived_from.effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "⚠ **판을 모르는 파일이 아니다** — 파일 안 `derived_from.effective_date` 에 2026-01-01 이 박혀 있다(건설품셈 8-4 운전경비 파생본 · 원본 지문까지 함께). `mach_fuel_rate`·`mach_operator_map` 이 다 차면 걷어낼 자리다.", + "files": [ + "resources/data_cost_machine_operating/machine_operating_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + }, + { + "source_id": "mat_price_public", + "kind": "material", + "kind_ko": "자재", + "name_ko": "관급자재 단가(시설공통자재)", + "publisher": "조달청", + "where_to_get": "", + "cycle": "반기 + 수시", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-08-14", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "수시 공고라 「최신 공표일」이 한 날로 안 정해진다 — 받은 날(우리 판)이 곧 기준이다. 철근·레미콘·아스콘은 이 벌에 없다(쇼핑몰 스냅숏 미확보).", + "files": [ + "resources/data_cost_input_value/mat_price_public_2026-08-14.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "oil", + "kind": "oil", + "kind_ko": "유가", + "name_ko": "유가 — 전국평균", + "publisher": "한국석유공사(오피넷)", + "where_to_get": "", + "cycle": "주간 · 일별", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-08-14", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "날마다 바뀌는 값이라 「최신」이 곧 오늘이다. **뒤처짐을 날짜 차이로 보여야 하는 자료**다. ⚠ 시도별 판과 「전국」 줄이 겹치는데 값도 기준일도 다르다(어느 쪽을 쓸지 판정 대기).", + "files": [ + "resources/data_cost_input_value/oil_2026-08-14.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "oil_regional", + "kind": "oil", + "kind_ko": "유가", + "name_ko": "유가 — 시도별", + "publisher": "한국석유공사(오피넷)", + "where_to_get": "", + "cycle": "주간 · 일별", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-09-09", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」를 따르려고 둔 벌이다.", + "files": [ + "resources/data_cost_input_value/oil_regional_2026-09-09.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "rates", + "kind": "rate", + "kind_ko": "요율", + "name_ko": "토목공사 원가계산 제비율 적용기준", + "publisher": "조달청", + "where_to_get": "", + "cycle": "해마다 + 수시(법이 바뀌면)", + "latest_published": "2026-04-13", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-04-13", + "our_edition_from": "effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "우리 판이 최신이다. 국민연금 요율은 국민연금법(현행 2026-06-17)을 따로 물었다. ⚠ 요율은 **법이 바뀌면 주기와 상관없이** 바뀐다 — 주기만 보고 안심하면 안 된다.", + "files": [ + "resources/data_cost_input_value/rates_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + } + ] +} diff --git a/resources/tester/test_master_sources.py b/resources/tester/test_master_sources.py new file mode 100644 index 00000000..ec2a25c1 --- /dev/null +++ b/resources/tester/test_master_sources.py @@ -0,0 +1,128 @@ +"""자료 출처표(`resources/data_master_sources/sources_2026-09-15.json`) 시험. + +이 표가 있는 까닭 — 마스터 파일 안에는 **제 판만** 있고 「최신이 무엇인지·어디서 받는지」가 +없어서 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). + +- `our_edition` 이 **실제 파일과 어긋나면 빨강** — 손으로 적힌 판이 굳는 것을 막는다 +- 뒤처짐 판정이 뒤집히면 빨강 +- 받는 자리를 모르는 줄에 **사유가 없으면 빨강**(주소를 지어내지 않기로 한 자리) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCES_PATH = ROOT / "resources" / "data_master_sources" / "sources_2026-09-15.json" + + +@pytest.fixture(scope="module") +def sources() -> dict: + return json.loads(SOURCES_PATH.read_text(encoding="utf-8")) + + +def _edition_of(path: Path) -> tuple[str, str]: + doc = json.loads(path.read_text(encoding="utf-8")) + if doc.get("effective_date"): + return doc["effective_date"], "effective_date" + derived = doc.get("derived_from") + if isinstance(derived, dict) and derived.get("effective_date"): + return derived["effective_date"], "derived_from.effective_date" + return "", "" + + +def test_출처표가_읽힌다(sources): + assert sources["dataset_id"] == "data_master_sources" + assert sources["sources"], "출처가 하나도 없다" + + +def test_가리키는_파일이_다_있다(sources): + missing = [ + path for row in sources["sources"] for path in row["files"] if not (ROOT / path).is_file() + ] + assert not missing, f"없는 파일을 가리킨다: {missing}" + + +def test_우리_판이_실제_파일과_같다(sources): + """⭐ 손으로 적힌 판이 굳으면 또 뒤처진다 — 파일에서 읽은 값과 대 본다.""" + wrong = [] + for row in sources["sources"]: + edition, where = _edition_of(ROOT / row["files"][0]) + if row["our_edition"] != edition: + wrong.append(f"{row['source_id']}: 표 {row['our_edition']} ≠ 파일 {edition}") + elif row["our_edition_from"] != where: + wrong.append(f"{row['source_id']}: 읽은 자리 {row['our_edition_from']} ≠ {where}") + assert not wrong, wrong + + +def test_뒤처짐_판정이_맞다(sources): + for row in sources["sources"]: + latest, ours = row["latest_published"], row["our_edition"] + if not latest or not ours: + assert row["status"] == "unknown", row["source_id"] + elif latest > ours: + assert row["status"] == "behind", row["source_id"] + else: + assert row["status"] == "current", row["source_id"] + + +def test_건설_노임이_뒤처진_것으로_선다(sources): + """2026-09-15 에 드러난 자리 — 우리 2026-01-01, 최신 2026-09-01.""" + row = next(r for r in sources["sources"] if r["source_id"] == "labor_const") + assert row["status"] == "behind" + assert row["our_edition"] == "2026-01-01" + assert row["latest_published"] == "2026-09-01" + # 주기를 7월 1일로 알던 것이 틀렸다 — 1.1 / 9.1 이다. + assert "9월 1일" in row["cycle"] + assert row["where_to_get"], "받는 자리를 받았는데 비어 있다" + + +def test_운전경비는_판_모름이_아니다(sources): + """파일 안 `derived_from.effective_date` 에 판이 박혀 있다 — 「모름」으로 적으면 안 된다.""" + row = next(r for r in sources["sources"] if r["source_id"] == "machine_operating") + assert row["our_edition"] == "2026-01-01" + assert row["our_edition_from"] == "derived_from.effective_date" + assert row["status"] != "unknown" + + +def test_모르는_칸에는_사유가_있다(sources): + """주소·최신 공표일을 지어내지 않기로 한 자리 — 빈 칸이면 사유가 있어야 한다.""" + bad = [] + for row in sources["sources"]: + if not row["where_to_get"] and not row.get("where_to_get_missing_reason"): + bad.append(f"{row['source_id']} — 받는 자리 사유 없음") + if not row["latest_published"] and not row.get("latest_missing_reason"): + bad.append(f"{row['source_id']} — 최신 공표일 사유 없음") + assert not bad, bad + + +def test_줄마다_갈래와_주기가_있다(sources): + known = set(sources["kinds"]) + for row in sources["sources"]: + assert row["kind"] in known, row["source_id"] + assert row["kind_ko"] == sources["kinds"][row["kind"]] + assert row["cycle"].strip(), row["source_id"] + assert row["publisher"].strip(), row["source_id"] + assert row["checked_on"].strip(), row["source_id"] + + +def test_기초단가_다섯_갈래를_다_덮는다(sources): + covered = {row["kind"] for row in sources["sources"]} + assert covered == {"labor", "machine", "material", "oil", "rate"} + + +def test_세어_둔_수가_실제와_같다(sources): + counts = sources["counts"] + assert counts["sources"] == len(sources["sources"]) + tallied: dict[str, int] = {} + for row in sources["sources"]: + tallied[row["status"]] = tallied.get(row["status"], 0) + 1 + assert counts["by_status"] == tallied + + +def test_상태_낱말이_다_풀려_있다(sources): + known = set(sources["status"]) + assert {row["status"] for row in sources["sources"]} <= known From 26a847babc806077cc421de5fab90b451f8f3a96 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:00:30 +0900 Subject: [PATCH 18/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=84=AF=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EB=85=B8=EC=9E=84=C2=B7=EA=B8=B0=EA=B3=84=C2=B7?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=C2=B7=EC=9C=A0=EA=B0=80=C2=B7=EC=9A=94?= =?UTF-8?q?=EC=9C=A8=EC=9D=84=20kind=20=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=ED=91=9C(=EC=9E=90=EB=A3=8C=20=EC=A0=9C=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=20@id=20=C2=B7=20=ED=8C=90=20=EA=B8=B0=EC=A4=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B9=B8)=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EA=B0=92=EC=9D=80?= =?UTF-8?q?=20=EB=8D=AE=EA=B0=9C=20resources/data=5Fmaster=5Foverride/{kin?= =?UTF-8?q?d}.json=20=EC=97=90=EB=A7=8C(=EC=9B=90=EB=B3=B8=EC=9D=80=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=EB=A7=8C)=20=C2=B7=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20=EB=8D=AE=EA=B0=9C=EB=8A=94=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=20=EA=B0=92=20=EC=9C=A0=EC=A7=80=20+=20source=5Fchang?= =?UTF-8?q?ed=20=C2=B7=20=EC=A3=BC=EC=9D=B8=20=EC=97=86=EC=9D=8C=EC=9D=80?= =?UTF-8?q?=20=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=AA=A8?= =?UTF-8?q?=EC=95=84=20=EB=B3=B4=EA=B8=B0(=EC=84=9C=EB=B2=84=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=C2=B7=20=EA=B1=B0=EB=A6=84=20=C2=B7=20=EC=AA=BD)?= =?UTF-8?q?=EC=99=80=20=ED=95=9C=EA=BA=BC=EB=B2=88=EC=97=90=20=EB=B9=84?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8B=B9=20=EB=8B=A8=EA=B0=80=EB=8A=94=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=9E=A0=EA=B7=B8?= =?UTF-8?q?=EA=B3=A0=20=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0(@formula)=20?= =?UTF-8?q?=C2=B7=20=EB=85=B8=EC=9E=84=C2=B7=EC=9C=A0=EA=B0=80=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=EA=B0=80=20=EA=B8=B0=EA=B3=84=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=97=90=20=EB=8B=BF=EC=9D=8C=20=C2=B7=20B09=20hourly=5Fcost?= =?UTF-8?q?=5Fof=20=EC=99=80=20=EB=8C=80=EC=A1=B0(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=95=BD=20=C2=B7=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 347 +++++++++++++++ .../Z01_MasterData_BasePrices_Machine.py | 213 +++++++++ Z01_MasterData/Z01_MasterData_Overrides.py | 166 +++++++ Z01_MasterData/Z01_MasterData_Router.py | 58 ++- resources/tester/test_z01_base_prices.py | 411 ++++++++++++++++++ 5 files changed, 1193 insertions(+), 2 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices.py create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices_Machine.py create mode 100644 Z01_MasterData/Z01_MasterData_Overrides.py create mode 100644 resources/tester/test_z01_base_prices.py diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py new file mode 100644 index 00000000..bbbcadb1 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -0,0 +1,347 @@ +"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약). + +표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만) +줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄) +`@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code · + 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]` · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) +원본 파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Tables as tables + +KINDS = ("labor", "machine", "material", "oil", "rate") +_KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" +_SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜" +_RATE_EDITABLE = ( + "rate_percent", + "base_amount_krw", + "minimum_estimated_amount_krw", + "minimum_total_construction_amount_krw", + "rate_from_2033_percent", + "manager_thresholds.default_estimated_amount_krw", + "manager_thresholds.civil_main_work_estimated_amount_krw", +) +_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" + + +def _source(file_id: str) -> Path: + labels = tables.load_labels() + entry = next((f for f in labels["files"] if f["file_id"] == file_id), None) + path = tables._file_path(entry) if entry else None + if path is None: + raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}") + return path + + +@lru_cache(maxsize=16) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def _doc(file_id: str) -> tuple[dict[str, Any], str]: + path = _source(file_id) + return _read(str(path), path.stat().st_mtime_ns), path.name + + +def _records(doc: dict[str, Any]) -> list[dict[str, Any]]: + return next( + v["records"] for v in doc["variables"].values() if isinstance(v.get("records"), list) + ) + + +def _labor() -> list[dict[str, Any]]: + rows = [] + for file_id in ("labor_const", "labor_mfg"): + doc, name = _doc(file_id) + dataset = doc.get("dataset_id") or file_id + for r in _records(doc): + # 기준일 — 두 판(건설 · 제조) 기준일이 달라 한 표에 섞임 → 칸으로 드러냄 + row = { + "@id": f"{dataset}/{r['occupation_code']}", + "@source": name, + "dataset_id": dataset, + "effective_date": doc.get("effective_date"), + **r, + } + row.setdefault("daily_wage_krw", None) # 미공표 직종 — 사람이 넣을 수 있음 + rows.append(row) + return rows + + +def _material() -> list[dict[str, Any]]: + doc, name = _doc("mat_price_public") + date = doc.get("effective_date") + return [ + {"@id": str(r["item_code"]), "@source": name, "effective_date": date, **r} + for r in _records(doc) + ] + + +def _oil() -> list[dict[str, Any]]: + rows = [] + for file_id in ("oil", "oil_regional"): + doc, name = _doc(file_id) + for variable, v in doc["variables"].items(): + head = {k: x for k, x in v.items() if k not in ("records", "value")} + common = { + "scope": v.get("scope"), + "fuel": variable, + "effective_date": doc.get("effective_date"), # 전국 · 지역 판 기준일이 다름 + **head, + } + if "records" not in v: + rows.append( + { + "@id": f"{v.get('scope')}/{variable}", + "@source": name, + **common, + "value": v.get("value"), + } + ) + for r in v.get("records") or []: + rows.append( + { + "@id": f"{v.get('scope')}/{variable}/{r['sido_code']}", + "@source": name, + **common, + **r, + } + ) + return rows + + +def _flat(d: dict[str, Any], prefix: str = "") -> dict[str, Any]: + out: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict): + out.update(_flat(v, f"{prefix}{k}.")) + elif not isinstance(v, list): + out[f"{prefix}{k}"] = v + return out + + +def _rate() -> list[dict[str, Any]]: + doc, name = _doc("rates") + rows = [] + for variable, v in doc["variables"].items(): + rows.append( + { + "@id": variable, + "@source": name, + "variable": variable, + "part": "", + "effective_date": doc.get("effective_date"), + **_flat(v), + } + ) + for part, items in v.items(): + if not (isinstance(items, list) and items and all(isinstance(x, dict) for x in items)): + continue + for item in items: + bracket = ";".join( + f"{k}={item[k]}" + for k in sorted(item) + if k not in _RATE_EDITABLE + and k != "formula" + and not isinstance(item[k], (dict, list)) + ) + rows.append( + { + "@id": f"{variable}/{part}/{bracket}", + "@source": name, + "variable": variable, + "part": part, + "effective_date": doc.get("effective_date"), + "base": v.get("base"), + **item, + } + ) + return rows + + +_BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate} + + +def base_rows(kind: str) -> list[dict[str, Any]]: + """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" + if kind == "machine": + doc, name = _doc("mach_base") + return machine.base_rows(name, doc.get("effective_date")) + return _BUILDERS[kind]() + + +def _machine_inputs() -> machine.Inputs: + """기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일.""" + labor = [r for r in overrides.overlay("labor", _labor()) if r["dataset_id"] == "labor_const"] + oil = [ + r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}" + ] + return machine.Inputs( + wages={ + str(r["occupation_code"]): None + if r["daily_wage_krw"] is None + else Decimal(str(r["daily_wage_krw"])) + for r in labor + }, + wage_date=labor[0]["effective_date"] if labor else None, + oil={r["fuel"]: Decimal(str(r["value"])) for r in oil}, + oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, + operating_date=_doc("pum_const")[0].get("effective_date"), + ) + + +def rows(kind: str) -> list[dict[str, Any]]: + """덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈.""" + out = overrides.overlay(kind, base_rows(kind)) + if kind == "machine": + inputs = _machine_inputs() + out = [machine.compute(r, inputs) for r in out] + return out + + +def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: + keys: dict[str, None] = {} + for r in all_rows: + keys.update(dict.fromkeys(k for k in r if not k.startswith("@"))) + return list(keys) + + +def spec(kind: str, columns: list[str]) -> dict[str, Any]: + """표 수준 — editable · locked · formula (· notice).""" + if kind == "machine": + return machine.spec(columns) + editable = { + "labor": ["daily_wage_krw"], + "material": ["price_krw"], + "oil": ["value"], + "rate": [c for c in _RATE_EDITABLE if c in columns], + }[kind] + keys = {"labor": {"dataset_id", "occupation_code"}, "material": {"item_code"}}.get(kind, set()) + if kind == "oil": + keys = {"scope", "fuel", "sido_code"} + if kind == "rate": + locked = { + c: "규칙·구간 칸(로직) — 원본 갱신으로만 바뀜" for c in columns if c not in editable + } + else: + locked = {c: _KEY if c in keys else _SOURCE for c in columns if c not in editable} + out: dict[str, Any] = {"editable": editable, "locked": locked, "formula": {}} + if kind == "rate": + out["notice"] = _RATE_NOTICE + return out + + +def row_label(kind: str, row: dict[str, Any] | None) -> str: + if row is None: + return "" + if kind == "labor": + return str(row.get("occupation_name") or "") + if kind == "machine": + return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip() + if kind == "material": + return str(row.get("specification") or row.get("classification_name") or "") + if kind == "oil": + return f"{row.get('source_product_name') or row.get('fuel')} {row.get('sido_name') or '전국평균'}" + named = (tables.load_labels().get("value_overrides") or {}).get( + f"rates::variables/{row['variable']}" + ) or {} + bracket = row["@id"].split("/", 2)[2].replace(";", " · ") if row["part"] else "" + return f"{named.get('name_ko') or row['variable']} {bracket}".strip() + + +def column_meta(kind: str, key: str) -> dict[str, Any]: + """열 이름 — 이름표 `synthetic_tables['@base_prices/{kind}']` → `columns[열key]` → 영문 key.""" + labels = tables.load_labels() + own = (labels.get("synthetic_tables") or {}).get(f"@base_prices/{kind}") or {} + named = next((c for c in own.get("columns") or [] if c.get("key") == key), None) + named = named or labels["columns"].get(key) or {} + return { + "key": key, + "label": named.get("name_ko") or key, + "unit": named.get("unit") or "", + "hidden": named.get("visible") is False, + } + + +def public(row: dict[str, Any]) -> dict[str, Any]: + """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" + return {k: v for k, v in row.items() if k != "@source"} + + +def table( + kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" +) -> dict[str, Any]: + all_rows = rows(kind) + columns = _columns(kind, all_rows) + needle = q.strip().lower() + hits = ( + [ + r + for r in all_rows + if needle in json.dumps(public(r), ensure_ascii=False, default=str).lower() + ] + if needle + else all_rows + ) + size = max(1, min(size, tables.MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + return { + "columns": [column_meta(kind, c) for c in columns], + "rows": [public(r) for r in hits[start : start + size]], + "total": len(hits), + **spec(kind, columns), + } + + +def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, dict[str, Any]]: + """고치기 — (상태 코드, 고친 뒤 그 줄 또는 막은 까닭).""" + base = next((r for r in base_rows(kind) if r["@id"] == row_id), None) + if base is None: + return 404, {"message": f"없는 줄: {row_id}"} + info = spec(kind, _columns(kind, rows(kind))) + for column, value in values.items(): + if column not in info["editable"] or column not in base: + reason = info["locked"].get(column) or "이 줄에 없는 칸" + return 400, {"message": f"못 고치는 칸 {column} — {reason}"} + problem = overrides.check_value(value) + if problem: + return 400, {"message": f"{column}: {problem}"} + overrides.write(kind, base, values, by) + return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) + + +def override_items( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict[str, Any]: + """모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수.""" + out = [] + counts: dict[str, dict[str, int]] = {} + for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만 + base = base_rows(each) + by_id = {r["@id"]: r for r in base} + for item in overrides.items(each, base): + counts.setdefault(each, {}).setdefault(item["state"], 0) + counts[each][item["state"]] += 1 + if (kind and each != kind) or (state and item["state"] != state): + continue + item["row_label"] = row_label(each, by_id.get(item["row_id"])) + item["column_label"] = column_meta(each, item["column"])["label"] + out.append(item) + out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"])) + size = max(1, min(size, tables.MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + return {"items": out[start : start + size], "total": len(out), "counts": counts} + + +def clear_overrides(kind: str, state: str) -> int: + return overrides.clear(kind, state, base_rows(kind)) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py new file mode 100644 index 00000000..ed1e3bfb --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py @@ -0,0 +1,213 @@ +"""Z01 기초단가 — 기계 한 표: 기종 한 줄 + 시간당 단가(계산값) · 산출근거(2026-09-15 브레인 계약). + +기계단가는 취득가 613 · 손료계수 387 · 운전경비(연료·잡재료·조종원 — 건설품셈 8-4) · 노임 · 유가가 계산으로 물림. + 고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **덮개 얹은 값**을 끌어 씀 + 계산 칸 = 손료 · 연료 · 조종원 · 시간당 단가 — editable 에서 빼고 locked 에 까닭 +⚠ 셈은 B09 함수를 그대로 부름(`load_machine_catalog` · `load_operating_records` · `hourly_machine_cost`) — 식을 두 벌로 안 둠. + `hourly_cost_of`(B09) 와 같은 값인지 `test_z01_base_prices.py` 가 대조. 반만 선 값(연료·조종원 빠짐)은 시간당 단가를 안 냄. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from decimal import Decimal +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import ( + OPERATOR_WAGE_FORMULA, + hourly_machine_cost, + load_machine_catalog, +) +from B09_Estimation.B09_Estimation_MachineOperating import FUEL_VARIABLES, load_operating_records +from Z01_MasterData.Z01_MasterData_Tables import RESOURCES + +#: B09 셈이 여는 원본 셋 — 바뀌면 캐시를 새로 읽음 +_INPUT_FILES = ("mach_base_2026.json", "pum_const_2026.json", "labor_const_2026-01-01.json") +EDITABLE = ["price_thousand_krw", "loss_coefficient_per_hour"] +COMPUTED = ("loss_krw_per_hour", "fuel_krw_per_hour", "operator_krw_per_hour", "hourly_krw") +#: 조종원 식 글자 — B09 식 문자열(좌→우 차례)을 사람이 읽게만 바꿈 +_WAGE_TEXT = OPERATOR_WAGE_FORMULA.replace("*", " × ").replace("/", " ÷ ") +_FORMULA = { + "loss_krw_per_hour": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)", + "fuel_krw_per_hour": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)", + "operator_krw_per_hour": ( + f"조종원 = 일 노임 × 인원 × {_WAGE_TEXT}" + " · 원 미만 절사 (건설품셈 8-1-2 · 건협 임금적용요령 4-나)" + ), + "hourly_krw": "시간당 사용료 = 손료 + 연료 + 조종원", +} +_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜" +_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님" +_LOCKED = { + "machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음", + "machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "specification": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "annual_standard_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "fuel_kind": _LOCKED_OPERATING, + "fuel_liters_per_hour": _LOCKED_OPERATING, + "misc_material_percent": _LOCKED_OPERATING, + "operator_occupation_code": _LOCKED_OPERATING, + "operator_person_days": _LOCKED_OPERATING, + "fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀", + "operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀", + "hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬", + "effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜", + "operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일", + "fuel_price_date": "끌어 쓴 유가 판 기준일 — 유가 표 판", + "operator_wage_date": "끌어 쓴 노임 판 기준일 — 노임 표 판", + **dict.fromkeys(COMPUTED, _LOCKED_COMPUTED), +} + + +def _num(value: Decimal | int | None) -> int | float | None: + if value is None: + return None + value = Decimal(value) + return int(value) if value == value.to_integral_value() else float(value) + + +def _fmt(value: Decimal | int | float) -> str: + text = f"{Decimal(str(value)):,.4f}".rstrip("0").rstrip(".") + return text + + +@dataclass(frozen=True) +class Inputs: + """기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께.""" + + wages: dict[str, Decimal | None] + wage_date: str | None + oil: dict[str, Decimal] + oil_dates: dict[str, str | None] + operating_date: str | None + + +@lru_cache(maxsize=2) +def _cached(_mtimes: tuple[int, ...]): + return load_machine_catalog(), {r.machine_code: r for r in load_operating_records().records} + + +def _inputs(): + folder = RESOURCES / "data_cost_input_value" + return _cached(tuple((folder / name).stat().st_mtime_ns for name in _INPUT_FILES)) + + +def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, Any]]: + """기종 한 줄 — 원본 칸(카탈로그가 되살린 이름·규격 · 손료계수 포함)과 운전경비 원단위.""" + catalog, records = _inputs() + rows = [] + for code, m in catalog.machines.items(): + record = records.get(code) + rows.append( + { + "@id": code, + "@source": source_name, + "machine_code": code, + "effective_date": effective_date, + "machine_name": m.name, + "specification": m.specification, + "price_thousand_krw": _num(m.price_thousand_krw), + "loss_coefficient_per_hour": _num(m.loss_coefficient_per_hour), + "economic_life_hours": m.economic_life_hours, + "annual_standard_hours": m.annual_standard_hours, + "fuel_kind": record.fuel_kind if record else None, + "fuel_liters_per_hour": _num(record.fuel_liters_per_hour) if record else None, + "misc_material_percent": _num(record.misc_material_percent) if record else None, + "operator_occupation_code": record.operator_occupation_code if record else None, + "operator_person_days": _num(record.operator_person_days) if record else None, + } + ) + return rows + + +def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: + """덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.""" + catalog, records = _inputs() + code = row["@id"] + coef = row["loss_coefficient_per_hour"] + record = records.get(code) + out = { + **row, + "operating_effective_date": inputs.operating_date if record is not None else None, + "fuel_price_krw_per_l": None, + "fuel_price_date": None, + "operator_daily_wage_krw": None, + "operator_wage_date": None, + **dict.fromkeys(COMPUTED), + } + if coef is None: + out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬" + return out + spec = replace( + catalog.machines[code], + price_thousand_krw=Decimal(str(row["price_thousand_krw"])), + loss_coefficient_per_hour=Decimal(str(coef)), + ) + liters = fuel_price = wage = None + if record is not None: + liters = record.fuel_liters_per_hour + if liters is not None: + variable = FUEL_VARIABLES.get(record.fuel_kind, "") + fuel_price = inputs.oil.get(variable) + out["fuel_price_date"] = inputs.oil_dates.get(variable) + if record.misc_material_percent is not None: + liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) + wage = inputs.wages.get(record.operator_occupation_code) + out["operator_daily_wage_krw"] = _num(wage) + out["operator_wage_date"] = inputs.wage_date + if wage is not None and record.operator_person_days is not None: + wage = wage * record.operator_person_days # 인원 칸이 비면 B09 처럼 일 노임 그대로 + cost = hourly_machine_cost( + spec, + fuel_liters_per_hour=liters if fuel_price is not None else None, + fuel_price_per_liter=fuel_price, + operator_daily_wage=wage, + ) + money = cost.money + out["fuel_price_krw_per_l"] = _num(fuel_price) + out["loss_krw_per_hour"] = _num(money.expense) + formula = { + "loss_krw_per_hour": ( + f"{_fmt(money.expense)} = {_fmt(spec.price_thousand_krw)}천원 × 1,000 × {spec.loss_coefficient_per_hour}" + ) + } + if fuel_price is not None: + out["fuel_krw_per_hour"] = _num(money.material) + misc = record.misc_material_percent if record else None + formula["fuel_krw_per_hour"] = ( + f"{_fmt(money.material)} = {_fmt(record.fuel_liters_per_hour)}ℓ" + + (f" × (1 + {_fmt(misc)}%)" if misc is not None else "") + + f" × {_fmt(fuel_price)}원/ℓ" + ) + if wage is not None: + out["operator_krw_per_hour"] = _num(money.labor) + persons = record.operator_person_days + formula["operator_krw_per_hour"] = ( + f"{_fmt(money.labor)} = {_fmt(out['operator_daily_wage_krw'])}원" + + (f" × {_fmt(persons)}인" if persons is not None else "") + + f" × {_WAGE_TEXT} (원 미만 절사)" + ) + if cost.gaps: + out["hourly_note"] = " · ".join(cost.gaps) + else: + out["hourly_krw"] = _num(money.total) + out["hourly_note"] = None + formula["hourly_krw"] = ( + f"{_fmt(money.total)} = {_fmt(money.expense)} + {_fmt(money.material)} + {_fmt(money.labor)}" + ) + out["@formula"] = formula + return out + + +def spec(columns: list[str]) -> dict[str, Any]: + return { + "editable": list(EDITABLE), + "locked": { + c: _LOCKED.get(c, "공표 원문 칸 — 원본 갱신으로만 바뀜") + for c in columns + if c not in EDITABLE + }, + "formula": dict(_FORMULA), + } diff --git a/Z01_MasterData/Z01_MasterData_Overrides.py b/Z01_MasterData/Z01_MasterData_Overrides.py new file mode 100644 index 00000000..5beeb6db --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Overrides.py @@ -0,0 +1,166 @@ +"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」). + + 원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움 + 덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일 + 읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}` + +state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음): + edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본) + source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함) + orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김 +되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌. +""" + +from __future__ import annotations + +import json +import math +import threading +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override" +EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan" +STATE_ORDER = (SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 확인할 것이 맨 위 +_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요 + + +def _path(kind: str) -> Path: + return OVERRIDE_DIR / f"{kind}.json" + + +def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]: + """`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개.""" + path = _path(kind) + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {} + + +def same(a: Any, b: Any) -> bool: + """값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음.""" + if a is None or b is None: + return a is None and b is None + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return Decimal(str(a)) == Decimal(str(b)) + return a == b + + +def check_value(value: Any) -> str | None: + """넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과.""" + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "숫자만 넣을 수 있음" + if not math.isfinite(value) or value < 0: + return "0 이상 유한한 수만 넣을 수 있음" + return None + + +def mark(original: Any, value: Any, current: Any) -> dict[str, Any]: + state = EDITED if same(original, current) else SOURCE_CHANGED + return {"original": original, "value": value, "state": state, "current": current} + + +def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**.""" + store = load(kind) + out = [] + for row in rows: + entries = store.get(row["@id"]) + if not entries: + out.append(row) + continue + row = dict(row) + marks = {} + for column, entry in entries.items(): + if column not in row: + continue # 주인 없는 칸 — 모아 보기에만 + marks[column] = mark(entry.get("original"), entry.get("value"), row[column]) + row[column] = entry.get("value") + if marks: + row["@overrides"] = marks + out.append(row) + return out + + +def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) -> None: + """고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림.""" + row_id = base_row["@id"] + with _LOCK: + store = load(kind) + entries = dict(store.get(row_id) or {}) + for column, value in values.items(): + current = base_row.get(column) + if value is None or same(value, current): + entries.pop(column, None) + continue + entries[column] = { + "value": value, + "original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed + "source": base_row.get("@source", ""), + "at": datetime.now().astimezone().isoformat(timespec="seconds"), + "by": by, + } + if entries: + store[row_id] = entries + else: + store.pop(row_id, None) + _save(kind, store) + + +def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None: + atomic_write_json( + _path(kind), + { + "schema_version": "1.0", + "kind": kind, + "note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀", + "overrides": store, + }, + ) + + +def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수.""" + with _LOCK: + doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state} + if not doomed: + return 0 + store = load(kind) + for row_id, column in doomed: + store[row_id].pop(column, None) + if not store[row_id]: + del store[row_id] + _save(kind, store) + return len(doomed) + + +def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로.""" + by_id = {r["@id"]: r for r in base_rows} + out = [] + for row_id, entries in load(kind).items(): + row = by_id.get(row_id) + for column, entry in entries.items(): + if row is None or column not in row: + current, state = None, ORPHAN + else: + current = row[column] + state = mark(entry.get("original"), entry.get("value"), current)["state"] + out.append( + { + "kind": kind, + "row_id": row_id, + "column": column, + "original": entry.get("original"), + "current": current, + "value": entry.get("value"), + "state": state, + } + ) + return out diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index e3e1e315..31b9e53c 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -1,19 +1,32 @@ -"""Z01 마스터 데이터 라우터 — 읽기 전용(고치기는 다음 차례 · 2026-09-15 브레인). +"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인). GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) + GET /api/master-data/base-prices/{kind}?page=&size=&q= 기초단가 한 표(labor|machine|material|oil|rate) + PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 + GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. """ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from typing import Any, Literal +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices from Z01_MasterData import Z01_MasterData_Tables as tables router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) +class BasePriceEdit(BaseModel): + values: dict[str, Any] + + @router.get("/tree") def get_tree() -> dict: return tables.tree() @@ -27,3 +40,44 @@ def get_rows( if result is None: raise HTTPException(status_code=404, detail="없는 파일이나 표입니다.") return result + + +def _kind(kind: str) -> str: + if kind not in base_prices.KINDS: + raise HTTPException(status_code=404, detail=f"없는 기초단가: {kind}") + return kind + + +@router.get("/base-prices/{kind}") +def get_base_prices( + kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" +) -> dict: + return base_prices.table(_kind(kind), page=page, size=size, q=q) + + +@router.put("/base-prices/{kind}/{row_id:path}") +def put_base_price( + kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session) +) -> JSONResponse: + status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id")) + if status != 200: + raise HTTPException(status_code=status, detail=payload["message"]) + return JSONResponse(content=payload) + + +@router.get("/overrides") +def get_overrides( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict: + return base_prices.override_items(kind and _kind(kind), state, page=page, size=size) + + +class OverrideClear(BaseModel): + kind: str + state: Literal["edited", "source_changed", "orphan"] + + +@router.post("/overrides/clear") +def clear_overrides(body: OverrideClear, _session: dict = Depends(verify_session)) -> dict: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — kind·state 둘 다 있어야 함(통째로 비우기 막음).""" + return {"removed": base_prices.clear_overrides(_kind(body.kind), body.state)} diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py new file mode 100644 index 00000000..5c904de5 --- /dev/null +++ b/resources/tester/test_z01_base_prices.py @@ -0,0 +1,411 @@ +"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시). + +틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개 +`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 · +덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) · +기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜. +""" + +from __future__ import annotations + +import hashlib +import json +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Router as router_module + +ROOT = Path(__file__).resolve().parents[2] +SOURCES = [ + ROOT / "resources/data_cost_input_value" / name + for name in ( + "labor_const_2026-01-01.json", + "labor_mfg_2026-07-01.json", + "mach_base_2026.json", + "mat_price_public_2026-08-14.json", + "oil_2026-08-14.json", + "oil_regional_2026-09-09.json", + "rates_2026.json", + ) +] + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + folder = tmp_path / "data_master_override" + monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder) + return folder + + +@pytest.fixture +def client(store: Path) -> TestClient: + from common_util.common_util_auth import verify_session + + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "SYSTEM_ADMIN"} + return TestClient(app) + + +def _get(client: TestClient, kind: str, **params) -> dict: + res = client.get(f"/api/master-data/base-prices/{kind}", params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _row(client: TestClient, kind: str, row_id: str) -> dict: + rows = _get(client, kind, q=row_id.rsplit("/", 1)[-1], size=500)["rows"] + return next(r for r in rows if r["@id"] == row_id) + + +def _put(client: TestClient, kind: str, row_id: str, values: dict): + return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values}) + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> None: + expected = { + "labor": (261, ["daily_wage_krw"]), + "material": (6999, ["price_krw"]), + "oil": (36, ["value"]), + "machine": (613, ["price_thousand_krw", "loss_coefficient_per_hour"]), + } + for kind, (total, editable) in expected.items(): + table = _get(client, kind, size=500) + assert table["total"] == total and table["editable"] == editable, kind + ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]] + assert len(ids) == len(set(ids)), kind + assert all("@overrides" not in r for r in table["rows"]), ( + kind + ) # 안 고친 줄엔 칸 자체가 없음 + columns = {c["key"] for c in table["columns"]} + assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖 + assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind + assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 + assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" + assert _row(client, "oil", "national_average/oil_diesel")["value"] == 1846.39 + assert _row(client, "oil", "regional/oil_diesel/01")["sido_name"] == "서울" + rate = _get(client, "rate", size=500) + assert rate["total"] == 218 and "rate_percent" in rate["editable"] + by_id = {r["@id"]: r for r in rate["rows"]} + assert by_id["rate_sanjae"]["rate_percent"] == 3.56 + goyong = ( + "rate_goyong/brackets/estimated_amount_bracket=gte_140_billion;grade=1" # 구간 칸 이름 차례 + ) + assert by_id[goyong]["rate_percent"] == 1.57 and by_id[goyong]["base"] == "total_labor_cost" + material = _get(client, "material", q="육각볼트", size=5) + assert 0 < material["total"] < 6999 and len(material["rows"]) == 5 + + +def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를_실음(client: TestClient) -> None: + table = _get(client, "machine", q="0101-0007", size=5) + assert "hourly_krw" in table["locked"] and "hourly_krw" in table["formula"] + row = next(r for r in table["rows"] if r["@id"] == "0101-0007") + parts = row["loss_krw_per_hour"] + row["fuel_krw_per_hour"] + row["operator_krw_per_hour"] + assert row["hourly_krw"] == pytest.approx(parts) + assert ( + "hourly_krw" in row["@formula"] + and f"{int(row['hourly_krw']):,}" in row["@formula"]["hourly_krw"] + ) + res = _put(client, "machine", "0101-0007", {"hourly_krw": 1}) + assert res.status_code == 400 and table["locked"]["hourly_krw"] in res.text + + +def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: + from B09_Estimation.B09_Estimation_MachineOperating import ( + hourly_cost_of, + load_operating_records, + ) + + codes = sorted(r.machine_code for r in load_operating_records().records)[::9] + rows = {r["@id"]: r for r in _get(client, "machine", size=500, page=1)["rows"]} + rows |= {r["@id"]: r for r in _get(client, "machine", size=500, page=2)["rows"]} + checked = 0 + from B09_Estimation.B09_Estimation_MachineCost import MachineCostError + + for code in codes: + try: + cost = hourly_cost_of(code) + except MachineCostError: # 손료계수 없는 기종 — B09 도 못 셈 + assert rows[code]["hourly_krw"] is None and rows[code]["hourly_note"], code + continue + if cost.gaps: + assert rows[code]["hourly_krw"] is None, code # 반만 선 값은 안 냄 + continue + assert rows[code]["hourly_krw"] == pytest.approx(float(cost.money.total)), code + checked += 1 + assert checked >= 5 + + +def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None: + before = {p: _sha(p) for p in SOURCES} + res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + assert res.status_code == 200, res.text + row = res.json() + assert row["daily_wage_krw"] == 230000 + assert row["@overrides"] == { + "daily_wage_krw": { + "original": 215907, + "value": 230000, + "state": overrides.EDITED, + "current": 215907, + } + } + assert ( + _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000 + ) + assert _put(client, "oil", "national_average/oil_diesel", {"value": 1900}).status_code == 200 + assert sorted(p.name for p in store.iterdir()) == [ + "labor.json", + "oil.json", + ] # kind 마다 한 파일 + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"] + assert ( + entry["original"] == 215907 + and entry["source"] == "labor_const_2026-01-01.json" + and entry["by"] == 42 + ) + assert {p: _sha(p) for p in SOURCES} == before + + +def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClient) -> None: + base = _row(client, "machine", "0101-0007") + operator = base["operator_occupation_code"] + wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] + _put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000}) + _put(client, "oil", "national_average/oil_diesel", {"value": 2000}) + _put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2}) + after = _row(client, "machine", "0101-0007") + assert after["operator_krw_per_hour"] > base["operator_krw_per_hour"] + assert after["fuel_krw_per_hour"] > base["fuel_krw_per_hour"] + assert after["loss_krw_per_hour"] == pytest.approx(base["loss_krw_per_hour"] * 2) + price = base["price_thousand_krw"] + assert after["@overrides"] == { + "price_thousand_krw": { + "original": price, + "value": price * 2, + "state": overrides.EDITED, + "current": price, + } + } # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜 + + +def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None: + _put(client, "material", "10023392", {"price_krw": 30}) + reverted = _put(client, "material", "10023392", {"price_krw": None}).json() + assert reverted["price_krw"] == 22 and "@overrides" not in reverted + assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200 + saved = json.loads((store / "material.json").read_text(encoding="utf-8")) + assert saved["overrides"] == {} + + +def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김( + client: TestClient, store: Path +) -> None: + store.mkdir() + (store / "labor.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "kind": "labor", + "overrides": { + "labor_const/1001": { + "daily_wage_krw": { + "value": 199000, + "original": 200000, + "source": "labor_const_2025-07-01.json", + } + }, + "labor_const/9999": { + "daily_wage_krw": { + "value": 1, + "original": 1, + "source": "labor_const_2025-07-01.json", + } + }, + }, + } + ), + encoding="utf-8", + ) + # 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이 + row = _row(client, "labor", "labor_const/1001") + assert row["daily_wage_krw"] == 199000 + mark = row["@overrides"]["daily_wage_krw"] + assert mark == { + "original": 200000, + "value": 199000, + "state": overrides.SOURCE_CHANGED, + "current": 215907, + } + _put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000}) + listed = client.get("/api/master-data/overrides").json() + assert [ + (i["row_id"], i["state"]) for i in listed["items"] + ] == [ # 서버가 정렬 — 원본 바뀜이 맨 위 + ("labor_const/1001", overrides.SOURCE_CHANGED), + ("labor_const/9999", overrides.ORPHAN), + ("labor_const/1002", overrides.EDITED), + ] + first = listed["items"][0] + assert set(first) == { + "kind", + "row_id", + "row_label", + "column", + "column_label", + "original", + "current", + "value", + "state", + } + assert first["row_label"] == "작업반장" and ( + first["original"], + first["current"], + first["value"], + ) == (200000, 215907, 199000) + assert listed["items"][1]["current"] is None + saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움 + # 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기 + kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json() + assert kept["@overrides"]["daily_wage_krw"] == { + "original": 215907, + "value": 199000, + "state": overrides.EDITED, + "current": 215907, + } + taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json() + assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken + + +def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜( + client: TestClient, store: Path +) -> None: + blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None) + row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json() + assert row["@overrides"]["daily_wage_krw"] == { + "original": None, + "value": 150000, + "state": overrides.EDITED, + "current": None, + } + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = ( + 140000 # 옛 판엔 값이 있었다고 치면 + ) + (store / "labor.json").write_text(json.dumps(saved), encoding="utf-8") + assert ( + _row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"] + == overrides.SOURCE_CHANGED + ) + + +def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: + rate = _get(client, "rate", size=1) + assert "갱신" in rate["notice"] and "주인" in rate["notice"] + assert "notice" not in _get(client, "material", size=1) + + +def test_막는_자리(client: TestClient) -> None: + assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400 + locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸 + assert locked.status_code == 400 and "공표 원문" in locked.text + for bad in (True, -1, "220000", float("nan")): + body = json.dumps({"values": {"daily_wage_krw": bad}}).encode() + res = client.put( + "/api/master-data/base-prices/labor/labor_const/1001", + content=body, + headers={"content-type": "application/json"}, + ) + assert res.status_code in (400, 422), bad + fee = next( + r + for r in _get(client, "rate", size=500)["rows"] + if r["@id"].startswith("rate_performance_guarantee_fee/brackets/") + ) + assert ( + _put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400 + ) # 그 줄엔 요율 칸이 없음 + assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56") + + +def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None: + """브레인 ① — 노임 두 판(건설 01-01 · 제조 07-01)·유가 두 판(전국 08-14 · 지역 09-09)이 한 표에 섞임.""" + assert _row(client, "labor", "labor_const/1001")["effective_date"] == "2026-01-01" + assert _row(client, "labor", "labor_mfg/1")["effective_date"] == "2026-07-01" + assert _row(client, "oil", "national_average/oil_diesel")["effective_date"] == "2026-08-14" + assert _row(client, "oil", "regional/oil_diesel/01")["effective_date"] == "2026-09-09" + assert _row(client, "material", "10023392")["effective_date"] == "2026-08-14" + assert _row(client, "rate", "rate_sanjae")["effective_date"] == "2026-04-13" + m = _row(client, "machine", "0101-0007") + assert (m["effective_date"], m["operating_effective_date"]) == ("2026-01-01", "2026-01-01") + assert (m["fuel_price_date"], m["operator_wage_date"]) == ("2026-08-14", "2026-01-01") + no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_kind"] is None) + assert no_record["operating_effective_date"] is None and no_record["hourly_krw"] is None + + +def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None: + """브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음.""" + store.mkdir() + gone = { + f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}} + for i in range(3) + } + (store / "material.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}), + encoding="utf-8", + ) + _put(client, "material", "10023392", {"price_krw": 30}) + _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + listed = client.get( + "/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"} + ).json() + assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN} + assert listed["counts"] == { + "material": {overrides.ORPHAN: 3, overrides.EDITED: 1}, + "labor": {overrides.EDITED: 1}, + } + paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json() + assert paged["total"] == 5 and len(paged["items"]) == 2 + everything = client.get("/api/master-data/overrides").json()[ + "items" + ] # labor 가 kind 차례로 앞이어도 + assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2 + res = client.post( + "/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN} + ) + assert res.status_code == 200 and res.json()["removed"] == 3 + left = client.get("/api/master-data/overrides").json() + assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례 + ("labor", overrides.EDITED), + ("material", overrides.EDITED), + ] + assert ( + client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code + == 422 + ) # 거름 없이 통째로 못 비움 + + +def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None: + rates = _get(client, "rate", size=500)["rows"] + fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")) + store.mkdir() + entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}} + (store / "rate.json").write_text( + json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8" + ) + items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"] + assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)] + assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음 From 3a5132d9054b0b1efb3d5080f33c7dec13421411 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:01:09 +0900 Subject: [PATCH 19/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=A4=80=EC=9D=BC?= =?UTF-8?q?=20=EC=B9=B8(=EC=97=86=EC=9C=BC=EB=A9=B4=20=E3=80=8C=ED=8C=90?= =?UTF-8?q?=20=EB=AA=A8=EB=A6=84=E3=80=8D)=20=C2=B7=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=C2=B7=EC=B9=B8=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EA=B0=92=EC=97=90=20=EC=89=BC=ED=91=9C=EC=99=80=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84(=E3=80=8C=EC=9B=90=EB=9E=98=20=EA=B0=92=2017=20L=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=E3=80=8D)=20=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=EC=95=88?= =?UTF-8?q?=20=EB=90=98=EB=A9=B4=20=EC=B9=B8=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A4=84=EC=97=90=20=EA=B9=8C=EB=8B=AD=EC=9D=B4=20=EB=82=A8?= =?UTF-8?q?=EC=9D=8C(=EC=88=98=20=EB=AA=BB=20=EC=9D=BD=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B1=B0=EC=A0=88)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=20=EA=B2=83=20=EB=AA=A9=EB=A1=9D=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=EC=89=BC=ED=91=9C=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20?= =?UTF-8?q?=EB=92=A4=20=EC=84=A0=ED=83=9D=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80(=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EC=85=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 20 ++++++++++-- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 39 ++++++++++++++++------- Z01_MasterData/Z01_MasterData_UI_Side.ts | 5 +++ resources/tester/test_z01_master_cells.py | 26 ++++++++++++++- ui_template/ui_template_locale_b3.ts | 6 +++- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 09a9b098..75d03625 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -33,11 +33,25 @@ export function cellText(value: unknown, unit?: string | null): string { return String(value); } +/** 줄마다 판의 기준일 — 기초단가 한 표에 시기가 다른 판이 섞여 줄마다 보임(2026-09-15 브레인). */ +export const EFFECTIVE_DATE_KEY = "effective_date"; + /** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */ export function rowCells(row: Record, columns: MasterColumn[]): string[] { - return columns.map((column) => - Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—", - ); + return columns.map((column) => { + // 기준일 없는 자료(machine_operating 등)는 빈칸이 아니라 모른다고 말함. + if (column.key === EFFECTIVE_DATE_KEY) return cellText(row[column.key]) || "판 모름"; + return Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—"; + }); +} + +/** 단추 글자·칸 설명에 넣는 값 — 수는 늘 쉼표 · 단위(이름표)를 뒤에 붙여 무엇인지 보이게. */ +export function valueWithUnit(value: unknown, unit?: string | null): string { + const text = + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }) + : cellText(value, unit); + return text && unit ? `${text} ${unit}` : text; } /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 75e6cca4..07340c49 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -12,7 +12,6 @@ import { t as L } from "@ui/ui_template_locale"; import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { cellInfo, - cellText, clampPage, columnTitle, latestOnly, @@ -20,6 +19,7 @@ import { parseCellInput, rowCells, shownColumns, + valueWithUnit, type CellInfo, type MasterColumn, } from "./Z01_MasterData_UI_Cells"; @@ -51,10 +51,10 @@ function fill(key: Parameters[0], value: string): string { /** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ function stateText(info: CellInfo, column: MasterColumn): string { - const original = cellText(info.original, column.unit); + const original = valueWithUnit(info.original, column.unit); switch (info.state) { case "source_changed": - return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; case "orphan": return L("Z01_MasterData_State_Orphan"); case "missing": @@ -201,6 +201,7 @@ export function buildRowsView(): RowsView { td.classList.add("is-selected"); td.addEventListener("click", () => { if (td.querySelector("input")) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); selectCell(index, column); td.classList.add("is-selected"); if (canEdit) startEdit(td, index, column); @@ -225,15 +226,22 @@ export function buildRowsView(): RowsView { next.disabled = end.disabled = state.page >= pages; } - /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ - function selectCell(index: number, column: MasterColumn): void { + /** + * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. + * `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③). + */ + function selectCell(index: number, column: MasterColumn, failure?: string): void { if (!last) return; - grid.querySelector("td.is-selected")?.classList.remove("is-selected"); selected = { index, key: column.key }; const row = last.rows[index]; const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (failure) { + const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure)); + failed.classList.add("is-warning"); + lines.push(failed); + } if (source?.save) { lines.push( line( @@ -250,10 +258,10 @@ export function buildRowsView(): RowsView { // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). const target = info.state === "source_changed" - ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) + ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) : info.state === "orphan" ? null - : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); + : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); if (target && source?.save) { lines.push( createButton({ @@ -283,8 +291,14 @@ export function buildRowsView(): RowsView { if (finished) return; finished = true; const value = save ? parseCellInput(input.value, current) : current; - if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); - if (value === undefined || value === current) { + if (value === undefined) { + const why = fill("Z01_MasterData_Edit_Invalid", input.value); + showToast(why, "error"); + draw(); + selectCell(index, column, why); + return; + } + if (value === current) { draw(); return; } @@ -314,8 +328,11 @@ export function buildRowsView(): RowsView { "success", ); } catch (error) { - showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"); + showToast(why, "error"); + if (from !== source || table !== last) return; draw(); + selectCell(index, column, why); } } diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index b47e4fc4..41eb24ef 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -20,6 +20,7 @@ import { saveBasePrice, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; +import { valueWithUnit } from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { @@ -147,6 +148,10 @@ async function loadOverrides(query: RowsQuery): Promise { ], rows: items.slice(start, start + query.size).map((item) => ({ ...item, + // 값은 쉼표를 붙여 — 목록 줄에는 단위가 안 실려 옴. + value: valueWithUnit(item.value), + original: valueWithUnit(item.original), + ...(Object.hasOwn(item, "current") ? { current: valueWithUnit(item.current) } : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), kind: L(`Z01_MasterData_Base_${item.kind}` as const), diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index d031a4de..7fcce953 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,20 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - shownColumns, + shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①) +const dated = [ + { key: "occupation_name", label: "직종명", unit: null, hidden: false }, + { key: "effective_date", label: "기준일", unit: null, hidden: false }, +]; +const editions = [ + rowCells({ occupation_name: "보통인부", effective_date: "2026-01-01" }, dated), + rowCells({ occupation_name: "기계운전", effective_date: null }, dated), + rowCells({ occupation_name: "굴착기" }, dated), +]; + // 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인) const meta = { editable: ["daily_wage_krw"], @@ -102,6 +113,15 @@ writeFileSync(process.argv[2], JSON.stringify({ parseCellInput("보통인부2", "보통인부"), parseCellInput("3.5", null), ].map((v) => (v === undefined ? "invalid" : v)), + editions, + withUnit: [ + valueWithUnit(17, "원"), + valueWithUnit(41500000, "원"), + valueWithUnit(0.17, ""), + valueWithUnit(12345.678, null), + valueWithUnit(null, "원"), + valueWithUnit("5억 미만", ""), + ], })); """ @@ -173,3 +193,7 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None # 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수 assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5] + # 기준일이 없으면 빈칸·「—」가 아니라 「판 모름」 + assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"] + # 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만 + assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index a170ecde..02460588 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -161,7 +161,11 @@ export const ui_locales_b3 = { Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], - Z01_MasterData_Edit_Invalid: ["수로 못 읽음 — 저장 안 함", "Not a number — not saved"], + Z01_MasterData_Edit_Invalid: [ + "「{value}」 를 수로 못 읽음", + "Cannot read 「{value}」 as a number", + ], + Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"], Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], From 4fe974bcaa0334799a6658484c1efd83e1d91184 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:06:05 +0900 Subject: [PATCH 20/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=20=EC=88=98=EC=A4=80=20=EC=B6=9C=EC=B2=98?= =?UTF-8?q?=20=E2=80=94=20=EC=B6=9C=EC=B2=98=ED=91=9C(data=5Fmaster=5Fsour?= =?UTF-8?q?ces)=20=EB=A5=BC=20kind=20=EB=A7=88=EB=8B=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9C=BC=EB=A1=9C(=EB=82=B4=EB=8A=94=20=EA=B3=B3=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EB=8A=94=20=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=C2=B7=20=EC=B5=9C=EC=8B=A0=20=EA=B3=B5?= =?UTF-8?q?=ED=91=9C=EC=9D=BC=20=C2=B7=20=EC=9A=B0=EB=A6=AC=20=ED=8C=90=20?= =?UTF-8?q?=C2=B7=20=ED=99=95=EC=9D=B8=ED=95=9C=20=EB=82=A0=20=C2=B7=20out?= =?UTF-8?q?dated)=20=C2=B7=20=EC=9A=B0=EB=A6=AC=20=ED=8C=90=EC=9D=80=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=ED=91=9C=20=EA=B8=80=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EB=9D=BC=20=EC=8B=A4=EC=A0=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=97=90=EC=84=9C=20=C2=B7=20=EB=92=A4=EC=B2=98?= =?UTF-8?q?=EC=A7=90=20=ED=8C=90=EC=A0=95=EC=9D=80=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=ED=8C=90=20=EA=B8=B0=EC=A4=80=EC=9D=BC?= =?UTF-8?q?=EC=9D=80=20=ED=95=9C=20=ED=95=A8=EC=88=98(effective=5Fdate=20?= =?UTF-8?q?=C2=B7=20=ED=8C=8C=EC=83=9D=EB=B3=B8=20derived=5Ffrom.effective?= =?UTF-8?q?=5Fdate)=20=C2=B7=20=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=97=90=20=EB=8B=A8=EC=9C=84(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 75 ++++++++++++++++++--- resources/tester/test_z01_base_prices.py | 64 ++++++++++++++++++ 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index bbbcadb1..b8bd4a3a 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +from datetime import date from decimal import Decimal from functools import lru_cache from pathlib import Path @@ -20,6 +21,9 @@ from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Tables as tables KINDS = ("labor", "machine", "material", "oil", "rate") +SOURCES_DIR = ( + tables.RESOURCES / "data_master_sources" +) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지 _KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" _SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜" _RATE_EDITABLE = ( @@ -53,6 +57,58 @@ def _doc(file_id: str) -> tuple[dict[str, Any], str]: return _read(str(path), path.stat().st_mtime_ns), path.name +def edition_of(doc: dict[str, Any]) -> str | None: + """판 기준일 — 자료마다 자리가 달라 **여기 한 곳**에서 뽑음(`effective_date` · 파생본은 `derived_from.effective_date`).""" + derived = doc.get("derived_from") + return doc.get("effective_date") or ( + derived.get("effective_date") if isinstance(derived, dict) else None + ) + + +def _date(text: Any) -> date | None: + try: + return date.fromisoformat(str(text)) + except ValueError: + return None + + +def sources(kind: str) -> list[dict[str, Any]]: + """표 수준 출처 — kind 하나에 판이 둘인 곳(노임·유가·기계)이 있어 목록. + + `our_edition` 은 출처표 글자가 아니라 **실제 파일**에서 읽음(글자가 굳으면 또 뒤처짐) · + `outdated` 판정은 서버만(최신 공표일 > 우리 판 · 둘 중 하나라도 모르면 None). + """ + found = sorted(SOURCES_DIR.glob("sources_*.json")) if SOURCES_DIR.is_dir() else [] + if not found: + return [] + listing = _read(str(found[-1]), found[-1].stat().st_mtime_ns) + out = [] + for entry in listing.get("sources") or []: + if entry.get("kind") != kind: + continue + editions = [] + for name in entry.get("files") or []: + path = (tables.ROOT / name).resolve() + if path.is_file() and path.is_relative_to(tables.RESOURCES): + editions.append(_date(edition_of(_read(str(path), path.stat().st_mtime_ns)))) + ours = min((e for e in editions if e), default=None) + latest = _date(entry.get("latest_published")) + out.append( + { + "source_id": entry.get("source_id"), + "name": entry.get("name_ko") or entry.get("source_id"), + "publisher": entry.get("publisher") or "", + "where": entry.get("where_to_get") or "", + "cycle": entry.get("cycle") or "", + "latest_published": latest.isoformat() if latest else None, + "our_edition": ours.isoformat() if ours else None, + "checked_at": entry.get("checked_on") or None, + "outdated": None if ours is None or latest is None else latest > ours, + } + ) + return out + + def _records(doc: dict[str, Any]) -> list[dict[str, Any]]: return next( v["records"] for v in doc["variables"].values() if isinstance(v.get("records"), list) @@ -70,7 +126,7 @@ def _labor() -> list[dict[str, Any]]: "@id": f"{dataset}/{r['occupation_code']}", "@source": name, "dataset_id": dataset, - "effective_date": doc.get("effective_date"), + "effective_date": edition_of(doc), **r, } row.setdefault("daily_wage_krw", None) # 미공표 직종 — 사람이 넣을 수 있음 @@ -80,7 +136,7 @@ def _labor() -> list[dict[str, Any]]: def _material() -> list[dict[str, Any]]: doc, name = _doc("mat_price_public") - date = doc.get("effective_date") + date = edition_of(doc) return [ {"@id": str(r["item_code"]), "@source": name, "effective_date": date, **r} for r in _records(doc) @@ -96,7 +152,7 @@ def _oil() -> list[dict[str, Any]]: common = { "scope": v.get("scope"), "fuel": variable, - "effective_date": doc.get("effective_date"), # 전국 · 지역 판 기준일이 다름 + "effective_date": edition_of(doc), # 전국 · 지역 판 기준일이 다름 **head, } if "records" not in v: @@ -140,7 +196,7 @@ def _rate() -> list[dict[str, Any]]: "@source": name, "variable": variable, "part": "", - "effective_date": doc.get("effective_date"), + "effective_date": edition_of(doc), **_flat(v), } ) @@ -161,7 +217,7 @@ def _rate() -> list[dict[str, Any]]: "@source": name, "variable": variable, "part": part, - "effective_date": doc.get("effective_date"), + "effective_date": edition_of(doc), "base": v.get("base"), **item, } @@ -176,7 +232,7 @@ def base_rows(kind: str) -> list[dict[str, Any]]: """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" if kind == "machine": doc, name = _doc("mach_base") - return machine.base_rows(name, doc.get("effective_date")) + return machine.base_rows(name, edition_of(doc)) return _BUILDERS[kind]() @@ -196,7 +252,7 @@ def _machine_inputs() -> machine.Inputs: wage_date=labor[0]["effective_date"] if labor else None, oil={r["fuel"]: Decimal(str(r["value"])) for r in oil}, oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, - operating_date=_doc("pum_const")[0].get("effective_date"), + operating_date=edition_of(_doc("pum_const")[0]), ) @@ -300,6 +356,7 @@ def table( "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), + "source": sources(kind), } @@ -335,7 +392,9 @@ def override_items( if (kind and each != kind) or (state and item["state"] != state): continue item["row_label"] = row_label(each, by_id.get(item["row_id"])) - item["column_label"] = column_meta(each, item["column"])["label"] + meta = column_meta(each, item["column"]) + item["column_label"] = meta["label"] + item["unit"] = meta["unit"] out.append(item) out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"])) size = max(1, min(size, tables.MAX_PAGE_SIZE)) diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 5c904de5..7e27ab9c 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -267,6 +267,7 @@ def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인 "current", "value", "state", + "unit", } assert first["row_label"] == "작업반장" and ( first["original"], @@ -409,3 +410,66 @@ def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestC items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"] assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)] assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음 + + +SOURCE_KEYS = { + "source_id", + "name", + "publisher", + "where", + "cycle", + "latest_published", + "our_edition", + "checked_at", + "outdated", +} + + +def test_표마다_출처와_뒤처짐을_서버가_판정(client: TestClient) -> None: + """브레인 — 건설 노임이 한 판 뒤처진 것을 반 년간 아무도 몰랐음 → 표 수준에 출처 · outdated 는 서버 판정.""" + labor = {s["source_id"]: s for s in _get(client, "labor", size=1)["source"]} + const = labor["labor_const"] + assert set(const) == SOURCE_KEYS + assert (const["our_edition"], const["latest_published"], const["outdated"]) == ( + "2026-01-01", + "2026-09-01", + True, + ) + assert "cak.or.kr" in const["where"] and const["publisher"] == "대한건설협회" + assert const["checked_at"] == "2026-09-15" + assert labor["labor_mfg"]["outdated"] is None # 최신 공표일 모름 + machine = {s["source_id"]: s for s in _get(client, "machine", size=1)["source"]} + assert ( + machine["machine_operating"]["our_edition"] == "2026-01-01" + ) # derived_from.effective_date + assert machine["mach_base"]["outdated"] is False + assert [s["source_id"] for s in _get(client, "oil", size=1)["source"]] == [ + "oil", + "oil_regional", + ] + + +def test_우리_판은_출처표_글자가_아니라_파일에서_읽음( + client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from Z01_MasterData import Z01_MasterData_BasePrices as base_prices + + real = sorted((ROOT / "resources/data_master_sources").glob("sources_*.json"))[-1] + doc = json.loads(real.read_text(encoding="utf-8")) + for s in doc["sources"]: + if s["source_id"] == "rates": # 표 글자가 굳어 틀려도 + s.update(our_edition="1999-01-01", latest_published="2027-01-01", status="current") + folder = tmp_path / "sources" + folder.mkdir() + (folder / "sources_2099-01-01.json").write_text(json.dumps(doc), encoding="utf-8") + monkeypatch.setattr(base_prices, "SOURCES_DIR", folder) + rate = _get(client, "rate", size=1)["source"] + assert [(s["our_edition"], s["outdated"]) for s in rate] == [("2026-04-13", True)] + monkeypatch.setattr(base_prices, "SOURCES_DIR", tmp_path / "없음") + assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로) + + +def test_모아_보기_항목에_단위(client: TestClient) -> None: + _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + (item,) = client.get("/api/master-data/overrides").json()["items"] + assert item["unit"] == "원" and item["column_label"] == "일 노임" From 43cb7c90d0fa09e903e794b80ee7c6a33fe9d365 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:08:08 +0900 Subject: [PATCH 21/48] =?UTF-8?q?feat(z01):=20=EB=92=A4=EC=B2=98=EC=A7=84?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=EB=9D=A0(=EC=84=9C=EB=B2=84=20source.o?= =?UTF-8?q?utdated=20=EB=A7=8C=20=C2=B7=20=E3=80=8C=EC=9D=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=EB=8A=94=20=E2=97=8B=20=ED=8C=90=20=C2=B7=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=EC=9D=80=20=E2=97=8B=20=EA=B3=B5=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EC=9E=90=EB=A6=AC=E3=80=8D)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=20=EA=B2=83=20=EB=AA=A8=EC=95=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=84=9C=EB=B2=84=20=EC=AA=BD=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=EA=B7=B8=EB=8C=80=EB=A1=9C(page=C2=B7size=20=C2=B7?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=B9=B8=20=EC=88=A8=EA=B9=80=20=C2=B7?= =?UTF-8?q?=20=EA=B0=92=EC=97=90=20unit)=20=C2=B7=20=EB=B6=80=EB=8F=99?= =?UTF-8?q?=EC=86=8C=EC=88=98=20=EB=81=9D=EC=9E=90=EB=A6=AC=20=EB=96=A0?= =?UTF-8?q?=EB=8F=8C=EC=9D=B4=20=EC=9E=90=EB=A6=84(=EC=86=90=EB=A3=8C?= =?UTF-8?q?=EA=B3=84=EC=88=98=200.0001811)=20=C2=B7=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20API=20=EB=A1=9C=20=EB=85=B8=EC=9E=84=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20=EB=90=98=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 10 ++++-- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 24 +++++++++++++ Z01_MasterData/Z01_MasterData_UI_Rows.ts | 21 +++++++++++- Z01_MasterData/Z01_MasterData_UI_Side.ts | 24 ++++++------- Z01_MasterData/Z01_MasterData_UI_Style.css | 11 ++++++ resources/tester/test_z01_master_cells.py | 39 ++++++++++++++++++++-- 6 files changed, 110 insertions(+), 19 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 78735084..e4daa002 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -80,12 +80,16 @@ export interface OverrideItem { original: unknown; current?: unknown; value: unknown; + unit?: string; state: OverrideState; } -/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */ -export async function fetchOverrides(): Promise { - return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items; +/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */ +export function fetchOverrides( + query: Pick, +): Promise<{ items: OverrideItem[]; total: number }> { + const params = new URLSearchParams({ page: String(query.page), size: String(query.size) }); + return request(`/master-data/overrides?${params}`); } /** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 75d03625..e7a1edf3 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -30,6 +30,8 @@ export function cellText(value: unknown, unit?: string | null): string { return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }); } if (typeof value === "object") return JSON.stringify(value); + // 부동소수 끝자리 떠돌이(0.00018109999999999998)를 15자리에서 잘라 원래 수로 보임. + if (typeof value === "number") return String(Number(value.toPrecision(15))); return String(value); } @@ -54,8 +56,30 @@ export function valueWithUnit(value: unknown, unit?: string | null): string { return text && unit ? `${text} ${unit}` : text; } +/** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */ +export interface TableSource { + publisher: string; + where: string; + cycle?: string; + latest_published?: string; + our_edition?: string; + checked_at?: string; + /** 뒤처짐 판정 — 서버만 함 */ + outdated?: boolean; +} + +/** + * 뒤처진 자료 띠 글자 — 서버가 outdated 라 할 때만. 화면은 날짜를 견주지 않음(판정 두 벌 금지). + * 받는 자리를 꼭 적음 — 최신이 무엇인지·어디서 받는지가 없어 건설 노임이 반 년 뒤처졌음. + */ +export function outdatedBanner(source?: TableSource): string | null { + if (source?.outdated !== true) return null; + return `이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher} ${source.where}`; +} + /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { + source?: TableSource; editable?: string[]; /** 열key → 왜 못 고치나 */ locked?: Record; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 07340c49..737bdd81 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -15,6 +15,7 @@ import { clampPage, columnTitle, latestOnly, + outdatedBanner, pageCount, parseCellInput, rowCells, @@ -34,6 +35,8 @@ export interface RowsSource { key: string; /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ notice?: string; + /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ + searchable?: boolean; load: (query: RowsQuery) => Promise; /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ save?: (rowId: string, values: Row) => Promise; @@ -97,6 +100,7 @@ export function buildRowsView(): RowsView { children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); const notice = el("p", { className: "z01-master__notice" }); + const banner = el("p", { className: "z01-master__outdated" }); const detail = el("div", { className: "z01-master__detail" }); const grid = el("div", { className: "z01-master__grid-wrap" }); const pageInput = el("input", { @@ -122,12 +126,21 @@ export function buildRowsView(): RowsView { }); const root = el("section", { className: "z01-master__panel", - children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager], + children: [ + el("div", { children: [title, titleKey] }), + banner, + notice, + toolbar, + detail, + grid, + pager, + ], }); toolbar.hidden = true; pager.hidden = true; detail.hidden = true; notice.hidden = true; + banner.hidden = true; function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -177,6 +190,10 @@ export function buildRowsView(): RowsView { function draw(): void { if (!last) return; const meta = last; + // 뒤처진 자료 띠 — 서버 outdated 만 봄(날짜 비교 안 함). + const outdated = outdatedBanner(meta.source); + banner.textContent = outdated ? `⚠ ${outdated}` : ""; + banner.hidden = !outdated; const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -343,6 +360,8 @@ export function buildRowsView(): RowsView { titleKey.textContent = picked.key; notice.textContent = picked.notice ?? ""; notice.hidden = !picked.notice; + banner.hidden = true; + search.root.hidden = picked.searchable === false; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 41eb24ef..7d1bb97a 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -62,6 +62,7 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme view.show({ title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, key: "overrides", + searchable: false, load: loadOverrides, }); }); @@ -126,15 +127,11 @@ const STATE_LABEL = { } as const; /** - * 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름. - * 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). + * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. + * 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김. */ async function loadOverrides(query: RowsQuery): Promise { - const needle = query.q.toLowerCase(); - const items = (await fetchOverrides()).filter( - (item) => !needle || JSON.stringify(item).toLowerCase().includes(needle), - ); - const start = (query.page - 1) * query.size; + const { items, total } = await fetchOverrides(query); const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); return { columns: [ @@ -146,17 +143,18 @@ async function loadOverrides(query: RowsQuery): Promise { column("original", "Z01_MasterData_List_Original"), column("current", "Z01_MasterData_List_Current"), ], - rows: items.slice(start, start + query.size).map((item) => ({ + rows: items.map((item) => ({ ...item, - // 값은 쉼표를 붙여 — 목록 줄에는 단위가 안 실려 옴. - value: valueWithUnit(item.value), - original: valueWithUnit(item.original), - ...(Object.hasOwn(item, "current") ? { current: valueWithUnit(item.current) } : {}), + value: valueWithUnit(item.value, item.unit), + original: valueWithUnit(item.original, item.unit), + ...(Object.hasOwn(item, "current") + ? { current: valueWithUnit(item.current, item.unit) } + : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), kind: L(`Z01_MasterData_Base_${item.kind}` as const), })), - total: items.length, + total, }; } diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index 824dbfff..d9b8be4e 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -125,6 +125,17 @@ font-size: var(--text-body); } +/* 뒤처진 자료 띠 — 받는 자리까지 적힘 · 또렷이 */ +.z01-master__outdated { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-canvas)); + color: var(--color-danger); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + /* 요율 알림 — 막지 않고 알리기만 */ .z01-master__notice { margin: 0; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 7fcce953..3dbf2b1d 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,25 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - shownColumns, valueWithUnit, + outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인) +const laborSource = { + publisher: "대한건설협회", + where: "cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", + cycle: "반년", + latest_published: "2026-09-01", + our_edition: "2026-01-01", + checked_at: "2026-09-15", + outdated: true, +}; +const banners = [ + outdatedBanner(laborSource), + outdatedBanner({ ...laborSource, outdated: false }), + outdatedBanner(undefined), +]; + // 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①) const dated = [ { key: "occupation_name", label: "직종명", unit: null, hidden: false }, @@ -91,6 +107,8 @@ writeFileSync(process.argv[2], JSON.stringify({ cellText({ a: 1 }, null), cellText(true, null), cellText("보통인부", null), + // 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것 + cellText(0.00018109999999999998, null), ], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), @@ -122,6 +140,7 @@ writeFileSync(process.argv[2], JSON.stringify({ valueWithUnit(null, "원"), valueWithUnit("5억 미만", ""), ], + banners, })); """ @@ -160,7 +179,16 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["shown"] == ["occupation_name", "daily_wage_krw"] assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"] assert got["titles"] == ["id", "직종명", "일 노임 (원)"] - assert got["cells"] == ["", "250,000", "1.23456", "2026", '{"a":1}', "예", "보통인부"] + assert got["cells"] == [ + "", + "250,000", + "1.23456", + "2026", + '{"a":1}', + "예", + "보통인부", + "0.0001811", + ] assert got["pages"] == [1, 140, 1] # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 assert got["row"] == ["—", "", "250,000"] @@ -197,3 +225,10 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"] # 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만 assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"] + # 날짜가 뒤처져 보여도 서버가 outdated=false 면 띠 없음 · 출처가 안 오면 띠 없음 + assert got["banners"] == [ + "이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · " + "대한건설협회 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", + None, + None, + ] From dd6bd0620a05e34ed210b80bd356cbb1aae4de11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 08:46:57 +0900 Subject: [PATCH 22/48] =?UTF-8?q?feat(z01):=20=ED=91=9C=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=84=B8=20=EC=A4=84=EC=9D=84=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B8=80=EC=9E=90=EB=A1=9C=20=E2=80=94?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=9C=20=EA=B0=92=EC=9D=B4=20=EB=8B=BF=EB=8A=94?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC(scope=5Fnotice=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EB=9D=BC=20=EA=B5=B5=EA=B2=8C)=20=C2=B7=20=EB=92=A4=EC=B2=98?= =?UTF-8?q?=EC=A7=84=20=EC=9B=90=EB=B3=B8=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84(=EC=B6=9C=EC=B2=98=EA=B0=80=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=B4=20=C2=B7=20outdated=20=EC=B0=B8?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20null=20=EC=9D=80=20=EB=AA=A8=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=20=EC=95=88=20=EC=84=B8=EC=9B=80)=20=C2=B7?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EC=9D=80=20=EC=84=9C=EB=B2=84=20notice(?= =?UTF-8?q?=EC=9A=94=EC=9C=A8=20=EB=AC=B8=EA=B5=AC=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EB=BA=8C=20=E2=80=94=20=EB=91=90=20?= =?UTF-8?q?=EB=B2=8C=20=EA=B8=88=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 25 ++++++++--- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 22 ++++++---- Z01_MasterData/Z01_MasterData_UI_Side.ts | 2 - Z01_MasterData/Z01_MasterData_UI_Style.css | 17 +++++++- resources/tester/test_z01_master_cells.py | 48 ++++++++++++++-------- ui_template/ui_template_locale_b3.ts | 4 -- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index e7a1edf3..77a7d5db 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -58,28 +58,41 @@ export function valueWithUnit(value: unknown, unit?: string | null): string { /** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */ export interface TableSource { + source_id?: string; + /** 원본 이름 — 한 표가 원본 여럿을 모아 어느 것이 뒤처졌는지 가려야 함 */ + name?: string; publisher: string; where: string; cycle?: string; latest_published?: string; our_edition?: string; checked_at?: string; - /** 뒤처짐 판정 — 서버만 함 */ - outdated?: boolean; + /** 뒤처짐 판정 — 서버만 함 · null 은 「모름」이라 띠를 안 세움 */ + outdated?: boolean | null; } /** * 뒤처진 자료 띠 글자 — 서버가 outdated 라 할 때만. 화면은 날짜를 견주지 않음(판정 두 벌 금지). * 받는 자리를 꼭 적음 — 최신이 무엇인지·어디서 받는지가 없어 건설 노임이 반 년 뒤처졌음. */ -export function outdatedBanner(source?: TableSource): string | null { - if (source?.outdated !== true) return null; - return `이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher} ${source.where}`; +export function outdatedBanner(sources?: TableSource[]): string[] { + return (sources ?? []) + .filter((source) => source.outdated === true) + .map((source) => { + const head = source.name ? `${source.name} — ` : ""; + const where = source.where ? ` ${source.where}` : ""; + return `${head}이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher}${where}`; + }); } /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { - source?: TableSource; + /** 한 표가 모은 원본마다 한 줄 */ + source?: TableSource[]; + /** 서버가 주는 알림 한 줄(요율 — 법이 정한 값) — 화면이 사전을 가지면 두 벌이 됨 */ + notice?: string; + /** 고친 값이 어디에 닿는지 — 새로 만드는 프로젝트부터 쓰임(서버가 줌) */ + scope_notice?: string; editable?: string[]; /** 열key → 왜 못 고치나 */ locked?: Record; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 737bdd81..0763909c 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -33,8 +33,6 @@ type Row = Record; export interface RowsSource { title: string; key: string; - /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ - notice?: string; /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ searchable?: boolean; load: (query: RowsQuery) => Promise; @@ -100,7 +98,8 @@ export function buildRowsView(): RowsView { children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); const notice = el("p", { className: "z01-master__notice" }); - const banner = el("p", { className: "z01-master__outdated" }); + const scope = el("p", { className: "z01-master__scope" }); + const banner = el("div", { className: "z01-master__outdated" }); const detail = el("div", { className: "z01-master__detail" }); const grid = el("div", { className: "z01-master__grid-wrap" }); const pageInput = el("input", { @@ -128,6 +127,7 @@ export function buildRowsView(): RowsView { className: "z01-master__panel", children: [ el("div", { children: [title, titleKey] }), + scope, banner, notice, toolbar, @@ -141,6 +141,7 @@ export function buildRowsView(): RowsView { detail.hidden = true; notice.hidden = true; banner.hidden = true; + scope.hidden = true; function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -190,10 +191,15 @@ export function buildRowsView(): RowsView { function draw(): void { if (!last) return; const meta = last; - // 뒤처진 자료 띠 — 서버 outdated 만 봄(날짜 비교 안 함). + // 표 머리 세 줄 — 모두 서버가 주는 글자 그대로(화면이 사전을 가지면 두 벌이 됨). + // ① 고친 값이 닿는 자리 ② 뒤처진 원본(서버 outdated 만 · 날짜 비교 안 함) ③ 그 밖 알림 + scope.textContent = meta.scope_notice ?? ""; + scope.hidden = !meta.scope_notice; const outdated = outdatedBanner(meta.source); - banner.textContent = outdated ? `⚠ ${outdated}` : ""; - banner.hidden = !outdated; + banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` }))); + banner.hidden = outdated.length === 0; + notice.textContent = meta.notice ?? ""; + notice.hidden = !meta.notice; const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -358,9 +364,9 @@ export function buildRowsView(): RowsView { state.page = 1; title.textContent = picked.title; titleKey.textContent = picked.key; - notice.textContent = picked.notice ?? ""; - notice.hidden = !picked.notice; + notice.hidden = true; banner.hidden = true; + scope.hidden = true; search.root.hidden = picked.searchable === false; toolbar.hidden = false; pager.hidden = false; diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 7d1bb97a..56111cb7 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -44,8 +44,6 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme view.show({ title: `${baseTitle} › ${label}`, key: `base-prices/${kind}`, - // 요율은 구간 줄에 코드가 없어 구간 이름으로 이음 — 법이 바뀌면 고친 값이 주인을 잃기 쉬움. - notice: kind === "rate" ? L("Z01_MasterData_Rate_Notice") : undefined, load: (query) => fetchBasePrices(kind, query), save: (rowId, values) => saveBasePrice(kind, rowId, values), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index d9b8be4e..c4894ce4 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -125,7 +125,22 @@ font-size: var(--text-body); } -/* 뒤처진 자료 띠 — 받는 자리까지 적힘 · 또렷이 */ +/* 고친 값이 닿는 자리 — 흐리면 「저장이 안 됐다」로 읽힘(2026-09-16 브레인) */ +.z01-master__scope { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-accent); + background: var(--color-mist-violet); + color: var(--color-accent); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + +/* 뒤처진 자료 띠 — 받는 자리까지 적힘 · 또렷이 · 원본마다 한 줄 */ +.z01-master__outdated p { + margin: 0; +} + .z01-master__outdated { margin: 0; padding: var(--spacing-8) var(--spacing-12); diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 3dbf2b1d..823fc746 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -29,18 +29,32 @@ import { } from "./Z01_MasterData_UI_Cells.js"; // 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인) -const laborSource = { - publisher: "대한건설협회", - where: "cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", - cycle: "반년", - latest_published: "2026-09-01", - our_edition: "2026-01-01", - checked_at: "2026-09-15", - outdated: true, -}; +// ⚠ 한 표가 원본 여럿을 모은 것이라 출처는 목록으로 옴(노임 = 건설업 + 제조업). +const laborSources = [ + { + source_id: "labor_const", + name: "건설업 시중노임단가", + publisher: "대한건설협회", + where: "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]", + cycle: "해마다 두 번", + latest_published: "2026-09-01", + our_edition: "2026-01-01", + checked_at: "2026-09-15", + outdated: true, + }, + { + source_id: "labor_mfg", + name: "중소제조업 직종별 임금", + publisher: "중소기업중앙회", + where: "", + latest_published: null, + our_edition: "2026-07-01", + outdated: null, + }, +]; const banners = [ - outdatedBanner(laborSource), - outdatedBanner({ ...laborSource, outdated: false }), + outdatedBanner(laborSources), + outdatedBanner([{ ...laborSources[0], outdated: false }]), outdatedBanner(undefined), ]; @@ -225,10 +239,12 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"] # 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만 assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"] - # 날짜가 뒤처져 보여도 서버가 outdated=false 면 띠 없음 · 출처가 안 오면 띠 없음 + # 뒤처진 원본만 한 줄씩 · 판정을 모르는 것(outdated null)은 안 세움 · 없으면 빈 목록 assert got["banners"] == [ - "이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · " - "대한건설협회 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", - None, - None, + [ + "건설업 시중노임단가 — 이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · " + "대한건설협회 cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]" + ], + [], + [], ] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 02460588..5b7bfdc9 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -148,10 +148,6 @@ export const ui_locales_b3 = { Z01_MasterData_Base_oil: ["유가", "Fuel"], Z01_MasterData_Base_rate: ["요율", "Rates"], Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], - Z01_MasterData_Rate_Notice: [ - "법이 정한 값 — 고치면 갱신 때 주인을 잃기 쉬움", - "Values set by law — edits may lose their row when the law is updated", - ], Z01_MasterData_Edit_Can: [ "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", "Editable — click, type, Enter to save (Esc cancels)", From 05bab3b61fcd3ebc5e077d773061514ab023c0d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 08:50:45 +0900 Subject: [PATCH 23/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=97=B4=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=EC=97=90=EC=84=9C=EB=A7=8C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=ED=95=A9=EC=B9=9C=20=ED=91=9C=20?= =?UTF-8?q?=EC=97=B4=20key=20=EB=A5=BC=20=EC=9D=B4=EB=A6=84=ED=91=9C=20mer?= =?UTF-8?q?ged=5Ftables=20=EC=97=90=20=EB=A7=9E=EC=B6=A4(=EB=85=B8?= =?UTF-8?q?=EC=9E=84=20source=20=C2=B7=20=EC=9C=A0=EA=B0=80=20source=C2=B7?= =?UTF-8?q?region=5Fcode=C2=B7region=5Fname=C2=B7price=5Fkrw=5Fper=5Fl=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EA=B3=84=20fuel=5Ftype=C2=B7fuel=5Frate=5Fl?= =?UTF-8?q?=5Fper=5Fhour=C2=B7operator=5Fperson=5Fper=5Fday=C2=B7hourly=5F?= =?UTF-8?q?loss=5Fkrw=C2=B7hourly=5Ffuel=5Fkrw=C2=B7hourly=5Foperator=5Fkr?= =?UTF-8?q?w=C2=B7hourly=5Ftotal=5Fkrw)=20=C2=B7=20=ED=91=9C=20=EC=88=98?= =?UTF-8?q?=EC=A4=80=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C(=EC=83=88=EB=A1=9C?= =?UTF-8?q?=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=EB=B6=80=ED=84=B0=20=EC=93=B0=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EC=9C=A8=EC=9D=80=20=EB=B2=95=EC=A0=95=EA=B0=92=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=EA=B9=8C=EC=A7=80)=20=C2=B7=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EB=B0=9B=EB=8A=94=20=EC=9E=90=EB=A6=AC=20=EB=B9=88?= =?UTF-8?q?=20=EA=B3=B3=EC=9D=80=20=E3=80=8C=EC=97=86=EC=9D=8C=E3=80=8D(?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8=20=E2=91=A0=E2=91=A1=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 57 ++++++++++------ .../Z01_MasterData_BasePrices_Machine.py | 38 +++++------ resources/tester/test_z01_base_prices.py | 67 +++++++++++++------ 3 files changed, 100 insertions(+), 62 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index b8bd4a3a..0b5c2d8e 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -3,7 +3,7 @@ 표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만) 줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄) `@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code · - 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]` · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) + 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]`(원본 값 그대로 · 열 이름은 이름표 것) · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) 원본 파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`). """ @@ -35,6 +35,10 @@ _RATE_EDITABLE = ( "manager_thresholds.default_estimated_amount_krw", "manager_thresholds.civil_main_work_estimated_amount_krw", ) +_SCOPE_NOTICE = ( + "여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜" + "(프로젝트는 만들 때 뜬 사본을 봄)" +) _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" @@ -98,7 +102,7 @@ def sources(kind: str) -> list[dict[str, Any]]: "source_id": entry.get("source_id"), "name": entry.get("name_ko") or entry.get("source_id"), "publisher": entry.get("publisher") or "", - "where": entry.get("where_to_get") or "", + "where": entry.get("where_to_get") or "없음", # 안 지어냄(브레인) "cycle": entry.get("cycle") or "", "latest_published": latest.isoformat() if latest else None, "our_edition": ours.isoformat() if ours else None, @@ -125,7 +129,7 @@ def _labor() -> list[dict[str, Any]]: row = { "@id": f"{dataset}/{r['occupation_code']}", "@source": name, - "dataset_id": dataset, + "source": dataset, "effective_date": edition_of(doc), **r, } @@ -148,9 +152,9 @@ def _oil() -> list[dict[str, Any]]: for file_id in ("oil", "oil_regional"): doc, name = _doc(file_id) for variable, v in doc["variables"].items(): - head = {k: x for k, x in v.items() if k not in ("records", "value")} + head = {k: x for k, x in v.items() if k not in ("records", "value", "scope")} common = { - "scope": v.get("scope"), + "source": v.get("scope"), "fuel": variable, "effective_date": edition_of(doc), # 전국 · 지역 판 기준일이 다름 **head, @@ -161,7 +165,7 @@ def _oil() -> list[dict[str, Any]]: "@id": f"{v.get('scope')}/{variable}", "@source": name, **common, - "value": v.get("value"), + "price_krw_per_l": v.get("value"), } ) for r in v.get("records") or []: @@ -170,7 +174,9 @@ def _oil() -> list[dict[str, Any]]: "@id": f"{v.get('scope')}/{variable}/{r['sido_code']}", "@source": name, **common, - **r, + "region_code": r["sido_code"], + "region_name": r["sido_name"], + "price_krw_per_l": r["value"], } ) return rows @@ -238,7 +244,7 @@ def base_rows(kind: str) -> list[dict[str, Any]]: def _machine_inputs() -> machine.Inputs: """기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일.""" - labor = [r for r in overrides.overlay("labor", _labor()) if r["dataset_id"] == "labor_const"] + labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"] oil = [ r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}" ] @@ -250,7 +256,7 @@ def _machine_inputs() -> machine.Inputs: for r in labor }, wage_date=labor[0]["effective_date"] if labor else None, - oil={r["fuel"]: Decimal(str(r["value"])) for r in oil}, + oil={r["fuel"]: Decimal(str(r["price_krw_per_l"])) for r in oil}, oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, operating_date=edition_of(_doc("pum_const")[0]), ) @@ -272,29 +278,35 @@ def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: return list(keys) +def notice(kind: str) -> list[str]: + """표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③).""" + return [_SCOPE_NOTICE] + ([_RATE_NOTICE] if kind == "rate" else []) + + def spec(kind: str, columns: list[str]) -> dict[str, Any]: - """표 수준 — editable · locked · formula (· notice).""" + """표 수준 — editable · locked · formula.""" if kind == "machine": return machine.spec(columns) editable = { "labor": ["daily_wage_krw"], "material": ["price_krw"], - "oil": ["value"], + "oil": ["price_krw_per_l"], "rate": [c for c in _RATE_EDITABLE if c in columns], }[kind] - keys = {"labor": {"dataset_id", "occupation_code"}, "material": {"item_code"}}.get(kind, set()) + keys = {"labor": {"source", "occupation_code"}, "material": {"item_code"}}.get(kind, set()) if kind == "oil": - keys = {"scope", "fuel", "sido_code"} + keys = {"source", "fuel", "region_code"} if kind == "rate": locked = { c: "규칙·구간 칸(로직) — 원본 갱신으로만 바뀜" for c in columns if c not in editable } else: locked = {c: _KEY if c in keys else _SOURCE for c in columns if c not in editable} - out: dict[str, Any] = {"editable": editable, "locked": locked, "formula": {}} - if kind == "rate": - out["notice"] = _RATE_NOTICE - return out + return { + "editable": editable, + "locked": locked, + "formula": {}, + } def row_label(kind: str, row: dict[str, Any] | None) -> str: @@ -307,7 +319,7 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str: if kind == "material": return str(row.get("specification") or row.get("classification_name") or "") if kind == "oil": - return f"{row.get('source_product_name') or row.get('fuel')} {row.get('sido_name') or '전국평균'}" + return f"{row.get('source_product_name') or row.get('fuel')} {row.get('region_name') or '전국평균'}" named = (tables.load_labels().get("value_overrides") or {}).get( f"rates::variables/{row['variable']}" ) or {} @@ -316,10 +328,12 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str: def column_meta(kind: str, key: str) -> dict[str, Any]: - """열 이름 — 이름표 `synthetic_tables['@base_prices/{kind}']` → `columns[열key]` → 영문 key.""" + """열 이름 — 이름표 `merged_tables[kind]` → `columns[열key]` → 영문 key(이름은 이름표에만 · 브레인 ②).""" labels = tables.load_labels() - own = (labels.get("synthetic_tables") or {}).get(f"@base_prices/{kind}") or {} - named = next((c for c in own.get("columns") or [] if c.get("key") == key), None) + merged = next( + (t for t in labels.get("merged_tables") or [] if t.get("key") in (kind, f"{kind}s")), {} + ) + named = next((c for c in merged.get("columns") or [] if c.get("key") == key), None) named = named or labels["columns"].get(key) or {} return { "key": key, @@ -356,6 +370,7 @@ def table( "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), + "notice": notice(kind), "source": sources(kind), } diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py index ed1e3bfb..350a84c8 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py @@ -25,17 +25,17 @@ from Z01_MasterData.Z01_MasterData_Tables import RESOURCES #: B09 셈이 여는 원본 셋 — 바뀌면 캐시를 새로 읽음 _INPUT_FILES = ("mach_base_2026.json", "pum_const_2026.json", "labor_const_2026-01-01.json") EDITABLE = ["price_thousand_krw", "loss_coefficient_per_hour"] -COMPUTED = ("loss_krw_per_hour", "fuel_krw_per_hour", "operator_krw_per_hour", "hourly_krw") +COMPUTED = ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw") #: 조종원 식 글자 — B09 식 문자열(좌→우 차례)을 사람이 읽게만 바꿈 _WAGE_TEXT = OPERATOR_WAGE_FORMULA.replace("*", " × ").replace("/", " ÷ ") _FORMULA = { - "loss_krw_per_hour": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)", - "fuel_krw_per_hour": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)", - "operator_krw_per_hour": ( + "hourly_loss_krw": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)", + "hourly_fuel_krw": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)", + "hourly_operator_krw": ( f"조종원 = 일 노임 × 인원 × {_WAGE_TEXT}" " · 원 미만 절사 (건설품셈 8-1-2 · 건협 임금적용요령 4-나)" ), - "hourly_krw": "시간당 사용료 = 손료 + 연료 + 조종원", + "hourly_total_krw": "시간당 사용료 = 손료 + 연료 + 조종원", } _LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜" _LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님" @@ -45,11 +45,11 @@ _LOCKED = { "specification": "공표 원문 칸 — 원본 갱신으로만 바뀜", "economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", "annual_standard_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", - "fuel_kind": _LOCKED_OPERATING, - "fuel_liters_per_hour": _LOCKED_OPERATING, + "fuel_type": _LOCKED_OPERATING, + "fuel_rate_l_per_hour": _LOCKED_OPERATING, "misc_material_percent": _LOCKED_OPERATING, "operator_occupation_code": _LOCKED_OPERATING, - "operator_person_days": _LOCKED_OPERATING, + "operator_person_per_day": _LOCKED_OPERATING, "fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀", "operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀", "hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬", @@ -112,11 +112,11 @@ def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, An "loss_coefficient_per_hour": _num(m.loss_coefficient_per_hour), "economic_life_hours": m.economic_life_hours, "annual_standard_hours": m.annual_standard_hours, - "fuel_kind": record.fuel_kind if record else None, - "fuel_liters_per_hour": _num(record.fuel_liters_per_hour) if record else None, + "fuel_type": record.fuel_kind if record else None, + "fuel_rate_l_per_hour": _num(record.fuel_liters_per_hour) if record else None, "misc_material_percent": _num(record.misc_material_percent) if record else None, "operator_occupation_code": record.operator_occupation_code if record else None, - "operator_person_days": _num(record.operator_person_days) if record else None, + "operator_person_per_day": _num(record.operator_person_days) if record else None, } ) return rows @@ -167,24 +167,24 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: ) money = cost.money out["fuel_price_krw_per_l"] = _num(fuel_price) - out["loss_krw_per_hour"] = _num(money.expense) + out["hourly_loss_krw"] = _num(money.expense) formula = { - "loss_krw_per_hour": ( + "hourly_loss_krw": ( f"{_fmt(money.expense)} = {_fmt(spec.price_thousand_krw)}천원 × 1,000 × {spec.loss_coefficient_per_hour}" ) } if fuel_price is not None: - out["fuel_krw_per_hour"] = _num(money.material) + out["hourly_fuel_krw"] = _num(money.material) misc = record.misc_material_percent if record else None - formula["fuel_krw_per_hour"] = ( + formula["hourly_fuel_krw"] = ( f"{_fmt(money.material)} = {_fmt(record.fuel_liters_per_hour)}ℓ" + (f" × (1 + {_fmt(misc)}%)" if misc is not None else "") + f" × {_fmt(fuel_price)}원/ℓ" ) if wage is not None: - out["operator_krw_per_hour"] = _num(money.labor) + out["hourly_operator_krw"] = _num(money.labor) persons = record.operator_person_days - formula["operator_krw_per_hour"] = ( + formula["hourly_operator_krw"] = ( f"{_fmt(money.labor)} = {_fmt(out['operator_daily_wage_krw'])}원" + (f" × {_fmt(persons)}인" if persons is not None else "") + f" × {_WAGE_TEXT} (원 미만 절사)" @@ -192,9 +192,9 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: if cost.gaps: out["hourly_note"] = " · ".join(cost.gaps) else: - out["hourly_krw"] = _num(money.total) + out["hourly_total_krw"] = _num(money.total) out["hourly_note"] = None - formula["hourly_krw"] = ( + formula["hourly_total_krw"] = ( f"{_fmt(money.total)} = {_fmt(money.expense)} + {_fmt(money.material)} + {_fmt(money.labor)}" ) out["@formula"] = formula diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 7e27ab9c..22c58a84 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -75,7 +75,7 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No expected = { "labor": (261, ["daily_wage_krw"]), "material": (6999, ["price_krw"]), - "oil": (36, ["value"]), + "oil": (36, ["price_krw_per_l"]), "machine": (613, ["price_thousand_krw", "loss_coefficient_per_hour"]), } for kind, (total, editable) in expected.items(): @@ -91,8 +91,8 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" - assert _row(client, "oil", "national_average/oil_diesel")["value"] == 1846.39 - assert _row(client, "oil", "regional/oil_diesel/01")["sido_name"] == "서울" + assert _row(client, "oil", "national_average/oil_diesel")["price_krw_per_l"] == 1846.39 + assert _row(client, "oil", "regional/oil_diesel/01")["region_name"] == "서울" rate = _get(client, "rate", size=500) assert rate["total"] == 218 and "rate_percent" in rate["editable"] by_id = {r["@id"]: r for r in rate["rows"]} @@ -107,16 +107,16 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를_실음(client: TestClient) -> None: table = _get(client, "machine", q="0101-0007", size=5) - assert "hourly_krw" in table["locked"] and "hourly_krw" in table["formula"] + assert "hourly_total_krw" in table["locked"] and "hourly_total_krw" in table["formula"] row = next(r for r in table["rows"] if r["@id"] == "0101-0007") - parts = row["loss_krw_per_hour"] + row["fuel_krw_per_hour"] + row["operator_krw_per_hour"] - assert row["hourly_krw"] == pytest.approx(parts) + parts = row["hourly_loss_krw"] + row["hourly_fuel_krw"] + row["hourly_operator_krw"] + assert row["hourly_total_krw"] == pytest.approx(parts) assert ( - "hourly_krw" in row["@formula"] - and f"{int(row['hourly_krw']):,}" in row["@formula"]["hourly_krw"] + "hourly_total_krw" in row["@formula"] + and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"] ) - res = _put(client, "machine", "0101-0007", {"hourly_krw": 1}) - assert res.status_code == 400 and table["locked"]["hourly_krw"] in res.text + res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1}) + assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: @@ -135,12 +135,12 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien try: cost = hourly_cost_of(code) except MachineCostError: # 손료계수 없는 기종 — B09 도 못 셈 - assert rows[code]["hourly_krw"] is None and rows[code]["hourly_note"], code + assert rows[code]["hourly_total_krw"] is None and rows[code]["hourly_note"], code continue if cost.gaps: - assert rows[code]["hourly_krw"] is None, code # 반만 선 값은 안 냄 + assert rows[code]["hourly_total_krw"] is None, code # 반만 선 값은 안 냄 continue - assert rows[code]["hourly_krw"] == pytest.approx(float(cost.money.total)), code + assert rows[code]["hourly_total_krw"] == pytest.approx(float(cost.money.total)), code checked += 1 assert checked >= 5 @@ -162,7 +162,10 @@ def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestC assert ( _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000 ) - assert _put(client, "oil", "national_average/oil_diesel", {"value": 1900}).status_code == 200 + assert ( + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code + == 200 + ) assert sorted(p.name for p in store.iterdir()) == [ "labor.json", "oil.json", @@ -182,12 +185,12 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien operator = base["operator_occupation_code"] wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] _put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000}) - _put(client, "oil", "national_average/oil_diesel", {"value": 2000}) + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000}) _put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2}) after = _row(client, "machine", "0101-0007") - assert after["operator_krw_per_hour"] > base["operator_krw_per_hour"] - assert after["fuel_krw_per_hour"] > base["fuel_krw_per_hour"] - assert after["loss_krw_per_hour"] == pytest.approx(base["loss_krw_per_hour"] * 2) + assert after["hourly_operator_krw"] > base["hourly_operator_krw"] + assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"] + assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2) price = base["price_thousand_krw"] assert after["@overrides"] == { "price_thousand_krw": { @@ -313,8 +316,11 @@ def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원 def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: rate = _get(client, "rate", size=1) - assert "갱신" in rate["notice"] and "주인" in rate["notice"] - assert "notice" not in _get(client, "material", size=1) + assert any("갱신" in line and "주인" in line for line in rate["notice"]) + for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로 + notice = _get(client, kind, size=1)["notice"] + assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind + assert len(rate["notice"]) == 2 and len(_get(client, "material", size=1)["notice"]) == 1 def test_막는_자리(client: TestClient) -> None: @@ -353,8 +359,8 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> m = _row(client, "machine", "0101-0007") assert (m["effective_date"], m["operating_effective_date"]) == ("2026-01-01", "2026-01-01") assert (m["fuel_price_date"], m["operator_wage_date"]) == ("2026-08-14", "2026-01-01") - no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_kind"] is None) - assert no_record["operating_effective_date"] is None and no_record["hourly_krw"] is None + no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_type"] is None) + assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None: @@ -473,3 +479,20 @@ def test_모아_보기_항목에_단위(client: TestClient) -> None: _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) (item,) = client.get("/api/master-data/overrides").json()["items"] assert item["unit"] == "원" and item["column_label"] == "일 노임" + + +def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: + """브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본.""" + labels = json.loads( + (ROOT / "resources/data_master_labels/labels_2026-01-01.json").read_text(encoding="utf-8") + ) + merged = {t["key"]: t for t in labels["merged_tables"]} + for kind in ("labor", "material", "oil"): + named = {c["key"]: c for c in merged[kind]["columns"]} + for column in _get(client, kind, size=1)["columns"]: + if column["key"] in named: + assert column["label"] == named[column["key"]]["name_ko"], (kind, column["key"]) + assert column["unit"] == named[column["key"]]["unit"] + assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], ( + kind + ) From 6c1956ec1888a2c2e7c75919b693d9b03d1262df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 10:51:04 +0900 Subject: [PATCH 24/48] =?UTF-8?q?fix(z01):=20=EC=9D=B4=EB=A6=84=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=20=ED=8B=80=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=EC=98=81=EB=AC=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=9C=A8=EB=8D=98=2019=EC=B9=B8=EC=97=90=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=9A=94=EC=9C=A8=20=EC=B6=95=20=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⓐ 다섯 표 열을 **서버 응답 그대로** 다시 적음(열 88 = 노임 12 · 기계 23 · 자재 15 · 유가 10 · 요율 28). ⇒ 영문으로 떨어지던 19칸 0 으로 — 기계 6(`operating_effective_date`·`fuel_price_krw_per_l`· `fuel_price_date`·`operator_daily_wage_krw`·`operator_wage_date`·`hourly_note`) · 요율 13(`part`·`base_rule`·`embedded_in`·`manager_thresholds.…` 따위) - ④ **요율 축을 한 칸으로 안 합침**(브레인 판정) — 등급·연도·공사 종류·대상액·추정금액·추정가격· 직접공사비·공사기간 여덟 축을 원문 칸 그대로 둠. 합치면 어느 축인지 사라지고 축이 둘 이상인 줄을 못 읽으며 `@id`(`{변수}/{목록}/{축=값;…}`)가 흔들림. 앞서 지어냈던 `item_key`·`bracket_basis` 는 걷음 - **「고칠 수 있나」를 이름표에서 뺌** — 서버 `spec()` 한 곳이 정함(판정 두 벌 금지) - ⓑ 새 시험 `test_master_labels_base_prices.py` 9건 — ⭐ **영문으로 떨어지는 열이 있으면 빨강**. 이름표 파일이 아니라 **서버 응답**을 재므로 두 창이 갈리면 그 자리에서 잡힘 · 묵은 열 이름표 · 요율 축 여덟 · 줄 열쇠에 축이 살아 있나 · 계산값 넷 잠김 · 단위까지 봄 - 왜 오늘 두 번 당했나 — 서버가 「못 찾으면 영문 key」로 **조용히 떨어지고**, 이름표 시험은 서버가 보는 자리를 안 봤음. 어긋나도 아무도 모르던 자리임 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 733 +++++++++++------- .../tester/test_master_labels_base_prices.py | 153 ++++ resources/tester/test_master_labels_cover.py | 44 +- 3 files changed, 623 insertions(+), 307 deletions(-) create mode 100644 resources/tester/test_master_labels_base_prices.py diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index ef78ff05..6577a6ae 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T21:46:51+09:00", + "generated_at": "2026-09-16T10:49:25+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -66,16 +66,22 @@ "name_ko": "출처", "unit": "", "visible": true, - "editable": true, "from": "새 칸", "note": "건설업(대한건설협회) · 제조업(중소기업중앙회). ⚠ 이 칸이 없으면 두 코드 공간이 섞인다." }, + { + "key": "effective_date", + "name_ko": "판 기준일", + "unit": "", + "visible": true, + "from": "둘 다", + "note": "두 공표의 판이 달라 한 표에 두 날짜가 선다." + }, { "key": "occupation_code", "name_ko": "직종코드", "unit": "", "visible": true, - "editable": true, "from": "둘 다" }, { @@ -83,23 +89,13 @@ "name_ko": "직종명", "unit": "", "visible": true, - "editable": true, "from": "둘 다" }, - { - "key": "industry", - "name_ko": "업종", - "unit": "", - "visible": true, - "editable": true, - "from": "제조업만" - }, { "key": "unit", "name_ko": "단위", "unit": "", "visible": true, - "editable": true, "from": "둘 다", "note": "둘 다 KRW/day — 어긋남 없음." }, @@ -108,7 +104,6 @@ "name_ko": "1일 근로시간", "unit": "시간", "visible": true, - "editable": true, "from": "건설업만" }, { @@ -116,15 +111,14 @@ "name_ko": "일 노임", "unit": "원", "visible": true, - "editable": true, - "from": "둘 다" + "from": "둘 다", + "note": "⚠ 30 줄은 칸 자체가 없다 — 미조사라 **값 없음이 제값**이다." }, { "key": "status", "name_ko": "공표 상태", "unit": "", "visible": true, - "editable": true, "from": "둘 다" }, { @@ -132,16 +126,28 @@ "name_ko": "신뢰도 기호", "unit": "", "visible": true, - "editable": true, "from": "건설업만", - "note": "* 조사현장 5개 미만 · ** 미조사. 뜻풀이가 원본에 있다." + "note": "* 조사현장 5개 미만 · ** 미조사. 없으면 정상 공표." + }, + { + "key": "industry", + "name_ko": "업종", + "unit": "", + "visible": true, + "from": "제조업만" + }, + { + "key": "variation_coefficient", + "name_ko": "변동계수", + "unit": "%", + "visible": true, + "from": "제조업만" }, { "key": "source_flag", "name_ko": "원문 표시", "unit": "", "visible": true, - "editable": true, "from": "제조업만", "note": "`*` 16 줄. ⚠ 뜻풀이가 원본에 없어 신뢰도 기호와 한 칸으로 못 묶는다." } @@ -156,103 +162,97 @@ ], "rows_note": "6,999 줄 — 다섯 가운데 가장 크다", "columns": [ + { + "key": "effective_date", + "name_ko": "판 기준일", + "unit": "", + "visible": true + }, { "key": "item_code", "name_ko": "물품코드", "unit": "", "visible": true, - "editable": true + "note": "⭐ 줄 열쇠 — 6,999 줄 겹침 0." }, { "key": "classification_code", "name_ko": "물품분류번호", "unit": "", - "visible": true, - "editable": true + "visible": true }, { "key": "classification_name", "name_ko": "물품분류명", "unit": "", - "visible": true, - "editable": true + "visible": true }, { "key": "specification", "name_ko": "규격", "unit": "", "visible": true, - "editable": true + "note": "⚠ 2 줄은 빈 문자열이다(`null` 아님)." }, { "key": "unit", "name_ko": "단위", "unit": "", - "visible": true, - "editable": true + "visible": true }, { "key": "price_krw", "name_ko": "단가", "unit": "원", - "visible": true, - "editable": true - }, - { - "key": "vat_basis", - "name_ko": "부가세 기준", - "unit": "", - "visible": true, - "editable": true - }, - { - "key": "price_type", - "name_ko": "가격 종류", - "unit": "", - "visible": true, - "editable": true - }, - { - "key": "delivery_condition", - "name_ko": "납품 조건", - "unit": "", - "visible": true, - "editable": true - }, - { - "key": "business_division_name", - "name_ko": "사업구분", - "unit": "", - "visible": true, - "editable": true - }, - { - "key": "field", - "name_ko": "분야", - "unit": "", - "visible": true, - "editable": true + "visible": true }, { "key": "notice_datetime", "name_ko": "공고 일시", "unit": "", - "visible": true, - "editable": true + "visible": true }, { "key": "notice_number", "name_ko": "공고번호", "unit": "", - "visible": false, - "editable": true + "visible": false }, { "key": "business_division_code", "name_ko": "사업구분 코드", "unit": "", - "visible": false, - "editable": true + "visible": false + }, + { + "key": "business_division_name", + "name_ko": "사업구분", + "unit": "", + "visible": true + }, + { + "key": "vat_basis", + "name_ko": "부가세 기준", + "unit": "", + "visible": true + }, + { + "key": "price_type", + "name_ko": "가격 종류", + "unit": "", + "visible": true + }, + { + "key": "delivery_condition", + "name_ko": "납품 조건", + "unit": "", + "visible": true + }, + { + "key": "field", + "name_ko": "분야", + "unit": "", + "visible": true } ] }, @@ -267,224 +267,320 @@ ], "rows_note": "전국평균 2 + 시도별 34 = 36 줄", "columns": [ - { - "key": "fuel", - "name_ko": "유종", - "unit": "", - "visible": true, - "editable": true, - "from": "원본 키", - "note": "휘발유 · 경유." - }, { "key": "source", "name_ko": "출처", "unit": "", "visible": true, - "editable": true, "from": "새 칸", - "note": "전국평균 판 · 시도별 판. 기준일이 판마다 다르다." + "note": "전국평균 판 · 시도별 판." }, { - "key": "region_code", - "name_ko": "시도코드", + "key": "fuel", + "name_ko": "유종", "unit": "", "visible": true, - "editable": true, - "from": "시도판", - "note": "전국평균 판은 `00` 으로 맞춘다." + "from": "원본 키", + "note": "휘발유(`oil_gasoline`) · 경유(`oil_diesel`)." }, { - "key": "region_name", - "name_ko": "시도명", + "key": "effective_date", + "name_ko": "판 기준일", "unit": "", "visible": true, - "editable": true, - "from": "시도판" + "from": "둘 다", + "note": "전국 판과 지역 판의 판이 다르다." }, { - "key": "price_krw_per_l", - "name_ko": "단가", - "unit": "원/L", + "key": "unit", + "name_ko": "단위", + "unit": "", "visible": true, - "editable": true, "from": "둘 다" }, { "key": "date", - "name_ko": "기준일", + "name_ko": "원천 조사일", "unit": "", "visible": true, - "editable": true, "from": "둘 다", - "note": "⚠ 판마다 다르다 — 같은 표에 두 날짜가 선다." - }, - { - "key": "source_product_name", - "name_ko": "원천 제품명", - "unit": "", - "visible": false, - "editable": true, - "from": "둘 다" + "note": "⚠ 판 기준일과 다른 칸이다 — 원천이 값을 잰 날." }, { "key": "source_product_code", "name_ko": "원천 제품코드", "unit": "", "visible": false, - "editable": true, "from": "둘 다" + }, + { + "key": "source_product_name", + "name_ko": "원천 제품명", + "unit": "", + "visible": true, + "from": "둘 다" + }, + { + "key": "price_krw_per_l", + "name_ko": "단가", + "unit": "원/L", + "visible": true, + "from": "둘 다" + }, + { + "key": "region_code", + "name_ko": "시도코드", + "unit": "", + "visible": true, + "from": "시도판", + "note": "전국평균 판은 `00`." + }, + { + "key": "region_name", + "name_ko": "시도명", + "unit": "", + "visible": true, + "from": "시도판" } ] }, { - "key": "rates", + "key": "rate", "name_ko": "제비율", "summary": "법정경비·일반관리비·이윤·부가세 요율 한 표. 원본 19 벌을 한 줄씩 펴서 모은다.", "sources": [ "rates::variables/* (19 벌)" ], - "rows_note": "한 값짜리 8 + 구간표 11 벌을 펴서 약 190 줄", + "rows_note": "한 값짜리 8 + 구간표 14 개를 펴서 약 190 줄", + "axis_note": "⭐ **구간 축을 한 칸으로 합치지 않는다**(2026-09-15 브레인 ④). 서버가 원문 칸을 그대로 펴고 이름표가 그 틀에 맞춘다 — 합치면 ㉠ 어느 축의 구간인지 사라지고 ㉡ 축이 둘 이상인 줄(안전관리비 = 대상액 × 공사 종류 · 간접노무비 = 직접공사비 × 공사기간 × 공사 종류)을 못 읽으며 ㉢ 고친 값을 거는 열쇠(`@id`)가 축 이름=값으로 서 있어 축이 죽으면 열쇠도 흔들린다.", "columns": [ { - "key": "item_key", - "name_ko": "항목 키", - "unit": "", - "visible": false, - "editable": true, - "from": "원본 키" - }, - { - "key": "item_name", - "name_ko": "항목", + "key": "variable", + "name_ko": "요율 항목", "unit": "", "visible": true, - "editable": true, - "from": "이름표" + "note": "`rate_sanjae`·`rate_goyong` … 19 벌." }, { - "key": "variant", - "name_ko": "벌", + "key": "part", + "name_ko": "원문 목록", "unit": "", "visible": true, - "editable": true, - "from": "새 칸", - "note": "한 항목이 표를 둘 가진 경우 — 일반관리비(토목/전문) · 기계 지급보증(종합/전문) · 환경보전비(임도 후보/전 공종)." + "note": "한 항목이 표를 여럿 가질 때 어느 목록인지 — `brackets`·`annual_rates`·`all_work_types`·`civil_landscape_industrial` 따위. 한 값짜리 줄은 빈다." }, { - "key": "work_type", - "name_ko": "공사 종류", + "key": "effective_date", + "name_ko": "판 기준일", "unit": "", - "visible": true, - "editable": true, - "from": "6 벌" - }, - { - "key": "amount_bracket", - "name_ko": "금액 구간", - "unit": "", - "visible": true, - "editable": true, - "from": "9 벌", - "note": "⚠ **축이 넷인데 한 칸으로 접힌다** — 추정금액(고용) · 추정가격(하도급·일반관리비·이윤) · 직접공사비(간접노무·기타경비·이행보증) · 대상액(안전관리비). 어느 금액인지는 곁 칸이 말한다." - }, - { - "key": "bracket_basis", - "name_ko": "구간이 무슨 금액인가", - "unit": "", - "visible": true, - "editable": true, - "from": "새 칸", - "note": "위 칸을 접으면 사라지는 것 — 반드시 함께 둔다." - }, - { - "key": "duration_bracket", - "name_ko": "공사기간 구간", - "unit": "", - "visible": true, - "editable": true, - "from": "2 벌" - }, - { - "key": "grade", - "name_ko": "등급", - "unit": "", - "visible": true, - "editable": true, - "from": "고용보험만" - }, - { - "key": "year", - "name_ko": "연도", - "unit": "", - "visible": true, - "editable": true, - "from": "국민연금만" + "visible": true }, { "key": "rate_percent", "name_ko": "요율", "unit": "%", "visible": true, - "editable": true, - "from": "18 벌", - "note": "이행보증은 요율이 아니라 산식이라 이 칸이 빈다." - }, - { - "key": "base_amount_krw", - "name_ko": "기초액", - "unit": "원", - "visible": true, - "editable": true, - "from": "안전관리비만" - }, - { - "key": "formula", - "name_ko": "산식", - "unit": "", - "visible": true, - "editable": true, - "from": "이행보증만" + "note": "이행보증만 산식이라 이 칸이 빈다." }, { "key": "base", "name_ko": "밑수", "unit": "", "visible": true, - "editable": true, - "from": "19 벌 다", "note": "이 요율을 어디에 곱하는가." }, { - "key": "minimum_krw", - "name_ko": "적용 하한", - "unit": "원", - "visible": true, - "editable": true, - "from": "3 벌", - "note": "이 금액에 못 미치면 줄이 안 선다 — 안전관리비 2천만 · 환경보전비 1억 · 퇴직공제 1억." - }, - { - "key": "note", - "name_ko": "비고", + "key": "grade", + "name_ko": "등급", "unit": "", "visible": true, - "editable": true, - "from": "원본 곁글" + "from": "고용보험", + "note": "축." + }, + { + "key": "estimated_amount_bracket", + "name_ko": "추정금액 구간", + "unit": "", + "visible": true, + "from": "고용보험", + "note": "축." + }, + { + "key": "year", + "name_ko": "연도", + "unit": "", + "visible": true, + "from": "국민연금", + "note": "축." + }, + { + "key": "target_amount_bracket", + "name_ko": "대상액 구간", + "unit": "", + "visible": true, + "from": "안전관리비", + "note": "축." + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true, + "from": "6 벌", + "note": "축." + }, + { + "key": "direct_cost_bracket", + "name_ko": "직접공사비 구간", + "unit": "", + "visible": true, + "from": "간접노무비·기타경비·이행보증", + "note": "축." + }, + { + "key": "duration_bracket", + "name_ko": "공사기간 구간", + "unit": "", + "visible": true, + "from": "간접노무비·기타경비", + "note": "축." + }, + { + "key": "estimated_price_bracket", + "name_ko": "추정가격 구간", + "unit": "", + "visible": true, + "from": "하도급보증·일반관리비·이윤", + "note": "축." + }, + { + "key": "amount_bracket", + "name_ko": "금액 구간", + "unit": "", + "visible": true, + "from": "안전관리비 기초액", + "note": "기초액이 걸리는 구간 — 위 축들과 달리 한 줄에만 있다." + }, + { + "key": "base_amount_krw", + "name_ko": "기초액", + "unit": "원", + "visible": true, + "from": "안전관리비" + }, + { + "key": "rate_from_2033_percent", + "name_ko": "2033년 이후 요율", + "unit": "%", + "visible": true, + "from": "국민연금", + "note": "표가 끝나는 해부터 쓰는 값." + }, + { + "key": "base_without_owner_supplied_material", + "name_ko": "대상액 — 관급자재 없을 때", + "unit": "", + "visible": true, + "from": "안전관리비", + "note": "재료비 + 직접노무비." + }, + { + "key": "base_with_owner_supplied_material", + "name_ko": "대상액 — 관급자재 있을 때", + "unit": "", + "visible": true, + "from": "안전관리비", + "note": "고시가 준 두 식 가운데 작은 쪽." + }, + { + "key": "base_rule", + "name_ko": "밑수 규칙", + "unit": "", + "visible": true, + "from": "안전관리비" + }, + { + "key": "minimum_total_construction_amount_krw", + "name_ko": "적용 하한 — 총공사금액", + "unit": "원", + "visible": true, + "from": "안전관리비", + "note": "이 금액에 못 미치면 줄이 안 선다(2천만 원)." + }, + { + "key": "minimum_estimated_amount_krw", + "name_ko": "적용 하한 — 추정금액", + "unit": "원", + "visible": true, + "from": "환경보전비·퇴직공제", + "note": "1억 원." + }, + { + "key": "manager_thresholds.default_estimated_amount_krw", + "name_ko": "안전관리자 선임 기준액 — 일반", + "unit": "원", + "visible": true, + "from": "안전관리비" + }, + { + "key": "manager_thresholds.civil_main_work_estimated_amount_krw", + "name_ko": "안전관리자 선임 기준액 — 토목 주공사", + "unit": "원", + "visible": true, + "from": "안전관리비" + }, + { + "key": "embedded_in", + "name_ko": "실린 자리", + "unit": "", + "visible": true, + "from": "안전관리비 기초액", + "note": "기초액이 따로 안 서고 요율표 안에 들어 있다는 표시." + }, + { + "key": "forest_road_selection_status", + "name_ko": "임도 줄 고름 상태", + "unit": "", + "visible": true, + "from": "환경보전비", + "note": "임도에 어느 줄을 쓸지 고름이 끝났는지 — 금액에 바로 닿는다." + }, + { + "key": "typical_forest_road_applicability", + "name_ko": "임도에 걸리는지", + "unit": "", + "visible": true, + "from": "이행보증", + "note": "보통 임도 규모에서 이 항목이 서는지." + }, + { + "key": "base_note", + "name_ko": "밑수 비고", + "unit": "", + "visible": true, + "from": "기계 지급보증" + }, + { + "key": "formula", + "name_ko": "산식", + "unit": "", + "visible": true, + "from": "이행보증", + "note": "요율이 아니라 식으로 주는 유일한 항목." } ] }, { "key": "machine", "name_ko": "기계경비", - "summary": "기종 한 줄 + 시간당 단가. ⚠ **한 표로 안 모인다** — 시간당 단가가 다섯 표를 물어 나오는 계산값이다.", + "summary": "기종 한 줄 + 시간당 단가. ⚠ **한 표로 안 모인다** — 시간당 단가가 여러 표를 물어 나오는 계산값이다.", "sources": [ "mach_base::variables/mach_price/records", "mach_base::variables/mach_loss_coef/records", "mach_base::variables/mach_fuel_rate/parsed_records", "mach_base::variables/mach_operator_map/explicit_mappings", - "mach_base::variables/mach_rock_adj/rules", - "machine_operating::records" + "machine_operating::records", + "labor_const::variables/labor_rate/records (조종원 노임)", + "oil::variables (연료 단가)" ], "rows_note": "취득가 613 줄이 뼈대 · 손료계수 387 · 연료 214 · 조종원 121 이 붙는다(안 붙는 줄은 빈 칸)", "columns": [ @@ -493,7 +589,14 @@ "name_ko": "기계코드", "unit": "", "visible": true, - "editable": true, + "from": "취득가", + "note": "⭐ 줄 열쇠 — 네 표 다 겹침 0." + }, + { + "key": "effective_date", + "name_ko": "판 기준일", + "unit": "", + "visible": true, "from": "취득가" }, { @@ -501,7 +604,6 @@ "name_ko": "기계명", "unit": "", "visible": true, - "editable": true, "from": "취득가" }, { @@ -509,33 +611,43 @@ "name_ko": "규격", "unit": "", "visible": true, - "editable": true, - "from": "취득가" + "from": "취득가", + "note": "⚠ 230 줄은 칸이 없다(규격 없는 기종)." }, { "key": "price_thousand_krw", "name_ko": "취득가", "unit": "천원", "visible": true, - "editable": true, "from": "취득가", - "note": "⭐ 밑값." + "note": "⚠ 여기만 **천원** 단위다 — 나머지 금액은 원." }, { "key": "loss_coefficient_per_hour", "name_ko": "시간당 손료계수", "unit": "", "visible": true, - "editable": true, - "from": "손료계수", - "note": "⭐ 밑값." + "from": "손료계수" + }, + { + "key": "economic_life_hours", + "name_ko": "내용시간", + "unit": "시간", + "visible": true, + "from": "손료계수" + }, + { + "key": "annual_standard_hours", + "name_ko": "연간표준가동시간", + "unit": "시간", + "visible": true, + "from": "손료계수" }, { "key": "fuel_type", "name_ko": "연료 종류", "unit": "", "visible": true, - "editable": true, "from": "연료" }, { @@ -543,52 +655,73 @@ "name_ko": "시간당 연료량", "unit": "L", "visible": true, - "editable": true, - "from": "연료", - "note": "⭐ 밑값." + "from": "연료" }, { "key": "misc_material_percent", "name_ko": "잡재료비", "unit": "연료비의 %", "visible": true, - "editable": true, - "from": "연료", - "note": "⭐ 밑값." + "from": "연료" }, { "key": "operator_occupation_code", - "name_ko": "조종원 직종", + "name_ko": "조종원 직종코드", "unit": "", "visible": true, - "editable": true, "from": "조종원", - "note": "⭐ 밑값 — 노임 표를 가리킨다." + "note": "노임 표를 가리킨다." }, { "key": "operator_person_per_day", "name_ko": "조종원", "unit": "인/일", "visible": true, - "editable": true, - "from": "조종원", - "note": "⭐ 밑값." + "from": "연료" }, { - "key": "rock_adjust_pct", - "name_ko": "암 작업 할증", - "unit": "%", + "key": "operating_effective_date", + "name_ko": "운전경비 판 기준일", + "unit": "", "visible": true, - "editable": true, - "from": "암석보정", - "note": "⭐ 밑값 — 기계 묶음별." + "from": "운전경비 파생본", + "note": "연료·조종원이 온 벌의 판. 취득가 판과 다를 수 있어 따로 보인다." + }, + { + "key": "fuel_price_krw_per_l", + "name_ko": "연료 단가", + "unit": "원/L", + "visible": true, + "from": "유가 표", + "note": "⭐ **다른 표에서 온 값** — 유가를 고치면 모든 기종이 움직인다." + }, + { + "key": "fuel_price_date", + "name_ko": "연료 단가 기준일", + "unit": "", + "visible": true, + "from": "유가 표" + }, + { + "key": "operator_daily_wage_krw", + "name_ko": "조종원 일 노임", + "unit": "원", + "visible": true, + "from": "노임 표", + "note": "⭐ **다른 표에서 온 값** — 노임을 고치면 모든 기종이 움직인다." + }, + { + "key": "operator_wage_date", + "name_ko": "조종원 노임 기준일", + "unit": "", + "visible": true, + "from": "노임 표" }, { "key": "hourly_loss_krw", "name_ko": "시간당 손료", "unit": "원", "visible": true, - "editable": false, "from": "계산", "note": "취득가 × 1,000 × 손료계수." }, @@ -597,27 +730,32 @@ "name_ko": "시간당 연료·잡재료", "unit": "원", "visible": true, - "editable": false, "from": "계산", - "note": "연료량 × 유가 × (1 + 잡재료비율). 유가는 **유가 표**에서 온다." + "note": "연료량 × 연료 단가 × (1 + 잡재료비율)." }, { "key": "hourly_operator_krw", "name_ko": "시간당 조종원", "unit": "원", "visible": true, - "editable": false, "from": "계산", - "note": "일 노임 ÷ 8 × 제수당 계수. 일 노임은 **노임 표**에서 온다." + "note": "일 노임 ÷ 8 × 제수당 계수 × 조종원 인수." }, { "key": "hourly_total_krw", "name_ko": "시간당 기계경비", "unit": "원", "visible": true, - "editable": false, "from": "계산", "note": "손료 + 운전경비. 수송비는 여기 안 든다." + }, + { + "key": "hourly_note", + "name_ko": "시간당 단가 비고", + "unit": "", + "visible": true, + "from": "계산", + "note": "못 채운 성분이 있으면 그 사실을 적는 자리 — 0 으로 안 때운다." } ] } @@ -629,48 +767,53 @@ "step": "① 손료", "formula": "취득가(천원) × 1,000 × 시간당 손료계수", "inputs": [ - "취득가", - "시간당 손료계수" + "price_thousand_krw", + "loss_coefficient_per_hour" ], - "note": "손료계수는 상각·정비·관리 계수의 합이다. 그 셋과 내용시간·연간표준가동시간도 밑값이라 함께 보인다." + "output": "hourly_loss_krw", + "note": "손료계수는 상각·정비·관리 계수의 합이다. 내용시간·연간표준가동시간도 함께 보인다." }, { "step": "② 운전경비 — 연료", - "formula": "시간당 연료량(L) × 유가(원/L) × (1 + 잡재료비율)", + "formula": "시간당 연료량(L) × 연료 단가(원/L) × (1 + 잡재료비율)", "inputs": [ - "시간당 연료량", - "잡재료비", - "유가 표" + "fuel_rate_l_per_hour", + "fuel_price_krw_per_l", + "misc_material_percent" ], - "note": "유가는 이 표에 없다 — **유가 표**에서 온다. 그래서 유가를 고치면 모든 기종이 움직인다." + "output": "hourly_fuel_krw", + "note": "연료 단가는 이 표의 값이 아니라 **유가 표**에서 온다 — 유가를 고치면 모든 기종이 움직인다." }, { "step": "③ 운전경비 — 조종원", "formula": "일 노임 ÷ 8시간 × 제수당 계수 × 조종원(인/일)", "inputs": [ - "조종원 직종", - "조종원 인/일", - "노임 표" + "operator_daily_wage_krw", + "operator_person_per_day", + "operator_occupation_code" ], - "note": "일 노임은 이 표에 없다 — **노임 표**에서 온다. 8시간은 품셈 8-1-6 단서다." + "output": "hourly_operator_krw", + "note": "일 노임은 **노임 표**에서 온다. 8시간은 품셈 8-1-6 단서다." }, { "step": "④ 암 작업 할증", "formula": "위 값 × (1 + 암 할증률)", "inputs": [ - "암 작업 할증" + "mach_rock_adj (기계 묶음별)" ], - "note": "기계 묶음별로 붙는다(불도저·굴착기·덤프)." + "output": "", + "note": "불도저·굴착기·덤프에 붙는다. 기종 표에는 안 서고 공종 쪽에서 걸린다." }, { "step": "⑤ 시간당 기계경비", - "formula": "① + ② + ③ (④ 를 걸친 뒤)", + "formula": "① + ② + ③", "inputs": [], - "note": "⚠ **수송비는 안 든다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이다." + "output": "hourly_total_krw", + "note": "⚠ **수송비는 안 든다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이다. 못 채운 성분은 0 으로 안 때우고 `hourly_note` 에 적는다." } ], - "editable_rule": "밑값 = 취득가 · 손료계수(와 그 속 계수들) · 연료량 · 잡재료비율 · 조종원 직종과 인수 · 암 할증률. 계산값 = 시간당 손료 · 시간당 연료 · 시간당 조종원 · 시간당 기계경비. ⚠ 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없게 된다 — 화면에서 막는다.", - "cross_table": "기계 표는 **유가 표와 노임 표를 문다**. 그 둘이 갱신되면 기계 시간당 단가가 다 바뀐다 — 갱신 차례는 노임·유가가 먼저, 기계가 나중이다." + "editable_rule": "밑값 = 취득가 · 손료계수(와 그 속 계수들) · 연료량 · 잡재료비율 · 조종원 직종과 인수. 계산값 = 시간당 손료 · 시간당 연료 · 시간당 조종원 · 시간당 기계경비. ⚠ 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없게 된다. ⚠ **어느 열이 고쳐지는지는 서버 `spec()` 한 곳이 정한다** — 이름표는 까닭만 적는다(판정 두 벌 금지).", + "cross_table": "기계 표는 **유가 표와 노임 표를 문다**(`fuel_price_krw_per_l`·`operator_daily_wage_krw`). 그 둘이 갱신되면 기계 시간당 단가가 다 바뀐다 — 갱신 차례는 노임·유가가 먼저, 기계가 나중이다." }, "merge_findings": { "renamed_same_column": [ @@ -689,8 +832,17 @@ "oil.value", "oil_regional.value" ], - "verdict": "합침", - "why": "둘 다 원/L 단가다. 이름만 `value` 로 같고 뜻도 같다 — 「단가」 한 칸으로 묶는다." + "verdict": "합침 — `price_krw_per_l`", + "why": "둘 다 원/L 단가다. 이름만 `value` 로 같고 뜻도 같다." + }, + { + "where": "유가", + "columns": [ + "effective_date", + "date" + ], + "verdict": "합치지 않음", + "why": "`effective_date` 는 **우리 판**, `date` 는 **원천이 값을 잰 날**이다. 닮아 보여 묶기 쉬운데 다른 것이다." } ], "one_side_only": [ @@ -715,8 +867,8 @@ "where": "유가", "only_in": "시도판", "columns": [ - "sido_code", - "sido_name" + "region_code", + "region_name" ], "note": "전국평균 판은 시도코드 `00`·시도명 「전국」으로 맞춘다." }, @@ -728,16 +880,26 @@ "year", "base_amount_krw", "formula", - "duration_bracket" + "duration_bracket", + "embedded_in" ], - "note": "19 벌이 축을 제각각 쓴다 — 안 쓰는 벌에서는 빈 칸이다." + "note": "19 벌이 축을 제각각 쓴다 — 안 쓰는 벌에서는 빈 칸이다. **합치지 않고 그대로 둔다.**" + }, + { + "where": "기계", + "only_in": "다른 표에서 옴", + "columns": [ + "fuel_price_krw_per_l", + "operator_daily_wage_krw" + ], + "note": "유가 표·노임 표에서 온다. 이 표에서 고치는 값이 아니다." } ], "unit_or_axis_mismatch": [ { "where": "제비율", - "what": "금액 구간 축이 넷", - "detail": "추정금액(고용보험) · 추정가격(하도급보증·일반관리비·이윤) · 직접공사비(간접노무비·기타경비·이행보증) · 대상액(안전관리비). 한 칸으로 접으면 「어느 금액인가」가 사라지므로 `bracket_basis` 칸을 함께 둔다." + "what": "구간 축이 다섯인데 **합치지 않는다**", + "detail": "추정금액(고용보험) · 추정가격(하도급보증·일반관리비·이윤) · 직접공사비(간접노무비·기타경비·이행보증) · 대상액(안전관리비) · 금액 구간(안전관리비 기초액). 서버가 원문 칸을 그대로 펴므로 이름표도 축마다 한 칸으로 둔다 — 합치면 어느 축인지 사라지고 축이 둘 이상인 줄을 못 읽는다(2026-09-15 브레인 ④ 판정)." }, { "where": "제비율", @@ -746,9 +908,14 @@ }, { "where": "기계", - "what": "취득가는 천원, 나머지 금액은 원", + "what": "취득가만 천원, 나머지 금액은 원", "detail": "원본이 천원 단위다. 한 표에 섞이면 1,000배 틀린다 — 열 이름에 단위를 못박는다." }, + { + "where": "기계", + "what": "판 기준일이 둘", + "detail": "`effective_date`(취득가 판)와 `operating_effective_date`(운전경비 파생본 판)가 다를 수 있다. 연료 단가·조종원 노임은 또 제 기준일을 달고 온다." + }, { "where": "노임", "what": "단위는 어긋나지 않음", @@ -759,12 +926,12 @@ { "where": "노임", "what": "직종코드 공간이 다르다", - "detail": "건설업은 네 자리(1001~), 제조업은 한두 자리(1·2·3…). **지금은 겹치는 코드가 0** 이지만 같은 칸에 넣으면 나중에 부딪힌다 — `source` 칸을 반드시 함께 둔다." + "detail": "건설업은 네 자리(1001~), 제조업은 한두 자리(1·2·3…). **지금은 겹치는 코드가 0** 이지만 같은 칸에 넣으면 나중에 부딪힌다 — 줄 열쇠가 `{dataset_id}/{occupation_code}` 로 서 있다." }, { "where": "유가", "what": "「전국」이 두 줄 선다", - "detail": "전국평균 판(휘발유 1,863.25 · 경유 1,846.39 · 2026-08-14)과 시도판 코드 `00`(1,858.93 · 1,843.79 · 2026-09-09)이 **값도 기준일도 다르다**. ⚠ 어느 쪽을 쓸지는 판정이 필요하다 — 이름표는 두 줄을 그대로 보이고 `source` 로 가른다." + "detail": "전국평균 판(휘발유 1,863.25 · 2026-08-14)과 시도판 `00`(1,858.93 · 2026-09-09)이 **값도 기준일도 다르다**. ⚠ 어느 쪽을 쓸지는 판정이 필요하다 — 이름표는 두 줄을 그대로 보이고 `source` 로 가른다." } ] }, @@ -777,7 +944,7 @@ "source", "occupation_code" ], - "checked": "건설업 132 줄·제조업 129 줄 각각 직종코드 겹침 0(전수). 다만 코드 공간이 달라(건설 네 자리 1001~ · 제조 한두 자리 1·2·3) **출처 칸과 묶어야** 한 표에서 안 부딪힌다." + "checked": "건설업 132 줄·제조업 129 줄 각각 직종코드 겹침 0(전수). 다만 코드 공간이 달라(건설 네 자리 1001~ · 제조 한두 자리 1·2·3) **출처와 묶어야** 한 표에서 안 부딪힌다. 서버가 `{dataset_id}/{occupation_code}` 로 세웠다." }, { "table": "material", @@ -786,7 +953,7 @@ "key": [ "item_code" ], - "checked": "6,999 줄 물품코드 겹침 0(전수). 그대로 열쇠로 쓸 수 있다." + "checked": "6,999 줄 물품코드 겹침 0(전수). 그대로 열쇠로 쓴다." }, { "table": "oil", @@ -797,18 +964,18 @@ "source", "region_code" ], - "checked": "원본이 유종마다 따로 선 표라 줄에는 시도코드뿐이다(유종별 17 줄 겹침 0). 유종은 표 이름에, 출처는 판에 있어 **줄 안에 없다** — 셋을 묶어야 줄이 하나로 가려진다." + "checked": "원본이 유종마다 따로 선 표라 줄에는 시도코드뿐이다(유종별 17 줄 겹침 0). 유종은 표 이름에, 출처는 판에 있어 **줄 안에 없다** — 셋을 묶어야 줄이 하나로 가려진다. 서버가 `{scope}/{변수}[/{sido_code}]` 로 세웠다." }, { - "table": "rates", + "table": "rate", "name_ko": "제비율", - "verdict": "없음 — 조건으로만 가려짐", + "verdict": "없음 — 축으로만 가려짐", "key": [ - "item_key", - "variant", - "(조건 열들)" + "variable", + "part", + "(축 열들)" ], - "checked": "19 벌에 id 칸이 아예 없다. 벌마다 조건 묶음으로 줄이 가려지는 것은 확인했다(14 개 구간표 전수 겹침 0). 다만 **조건 축이 벌마다 다르다** — 등급·연도·공사 종류·공사기간·금액 구간 넷. 한 끈으로 쓰려면 항목키+벌+조건을 이어야 한다." + "checked": "19 벌에 id 칸이 아예 없다. 벌마다 축 묶음으로 줄이 가려지는 것은 확인했다(14 개 구간표 전수 겹침 0). 서버가 `{변수}[/{목록}/{축=값;…}]` 로 세웠다 — ⚠ **축이 열쇠의 몸이라 축을 한 칸으로 합치면 열쇠가 흔들린다.**" }, { "table": "machine", @@ -912,7 +1079,7 @@ }, "counts": { "merged_tables": 5, - "merged_columns": 62, + "merged_columns": 88, "files": 34, "tables": 91, "columns": 455, diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py new file mode 100644 index 00000000..0e455f08 --- /dev/null +++ b/resources/tester/test_master_labels_base_prices.py @@ -0,0 +1,153 @@ +"""기초단가 다섯 표 — **열 이름이 영문으로 떨어지면 빨강**. + +이 시험이 있는 까닭(2026-09-15 크로스체크) — 세 창이 각자 초록이었는데도 +**26칸이 화면에 영문 key 로 뜨고 있었다.** 서버는 이름표에서 못 찾으면 영문 key 로 +조용히 떨어지게 짜여 있고, 이름표 쪽 시험은 서버가 실제로 보는 자리를 안 봤다. +**어긋나도 아무도 모르는 자리**였다. + +⚠ 그래서 이 시험은 이름표 파일만 읽지 않고 **서버가 내는 응답 그대로** 잰다. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices + +ROOT = Path(__file__).resolve().parents[2] +LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" + + +@pytest.fixture(scope="module") +def labels() -> dict: + return json.loads(LABELS_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def shown() -> dict[str, list[dict]]: + """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다.""" + out = {} + for kind in base_prices.KINDS: + columns = base_prices._columns(kind, base_prices.rows(kind)) + out[kind] = [base_prices.column_meta(kind, c) for c in columns] + return out + + +def test_다섯_표가_다_선다(shown): + assert set(shown) == {"labor", "machine", "material", "oil", "rate"} + for kind, columns in shown.items(): + assert columns, f"{kind} 에 열이 하나도 없다" + + +def test_영문으로_떨어지는_열이_하나도_없다(shown): + """⭐ 이 시험이 이번 판의 값진 자리 — 이름표가 서버 틀과 어긋나면 여기서 빨강.""" + english = [ + f"{kind}/{column['key']}" + for kind, columns in shown.items() + for column in columns + if column["label"] == column["key"] + ] + assert not english, ( + f"한글 이름이 없어 영문 key 로 뜨는 열 {len(english)}개: {english}\n" + "→ 이름표 `merged_tables[].columns` 에 그 key 를 더할 것." + ) + + +def test_열_이름이_비지_않았다(shown): + blank = [ + f"{kind}/{column['key']}" + for kind, columns in shown.items() + for column in columns + if not str(column["label"]).strip() + ] + assert not blank, blank + + +def test_이름표에_묵은_열이_남아_있지_않다(labels, shown): + """서버가 안 내는 열을 이름표가 들고 있으면 옛 틀이 굳은 것이다.""" + stale = [] + for entry in labels["merged_tables"]: + kind = entry["key"] + live = {c["key"] for c in shown.get(kind, ())} + for column in entry["columns"]: + if column["key"] not in live: + stale.append(f"{kind}/{column['key']}") + assert not stale, f"서버가 안 내는 묵은 열 이름표: {stale}" + + +def test_요율_축이_한_칸으로_안_합쳐졌다(shown): + """⭐ 2026-09-15 브레인 ④ — 합치면 어느 축의 구간인지 사라지고 `@id` 도 흔들린다. + + 축은 서버가 원문 칸 그대로 펴고 이름표가 거기에 맞춘다. + """ + keys = {column["key"] for column in shown["rate"]} + for axis in ( + "grade", + "year", + "work_type", + "target_amount_bracket", + "estimated_amount_bracket", + "estimated_price_bracket", + "direct_cost_bracket", + "duration_bracket", + ): + assert axis in keys, f"요율 축 {axis} 가 사라졌다" + + +def test_요율_줄_열쇠에_축이_살아_있다(): + """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다.""" + rows = base_prices.rows("rate") + multi = [r for r in rows if "/" in str(r["@id"])] + assert multi, "축이 든 줄 열쇠가 하나도 없다" + assert any("=" in str(r["@id"]) for r in multi) + assert len({r["@id"] for r in rows}) == len(rows), "줄 열쇠가 겹친다" + + +def test_기계_시간당_단가는_계산값으로_잠긴다(shown): + """밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다).""" + meta = base_prices.table("machine", page=1, size=1) + assert set(meta["formula"]) == { + "hourly_loss_krw", + "hourly_fuel_krw", + "hourly_operator_krw", + "hourly_total_krw", + } + for key in meta["formula"]: + assert key not in meta["editable"], f"{key} 는 계산값인데 고칠 수 있다" + + +def test_이름표는_고칠수있나를_안_정한다(labels): + """판정 두 벌 금지 — `editable` 은 서버만 정한다.""" + carried = [ + f"{entry['key']}/{column['key']}" + for entry in labels["merged_tables"] + for column in entry["columns"] + if "editable" in column + ] + assert not carried, f"이름표가 고칠 수 있나를 들고 있다: {carried}" + + +def test_단위가_붙어야_할_열에_단위가_있다(shown): + """「유가 1,858」만 떠서는 리터인지 드럼인지 모른다 — 금액·수량 열은 단위를 단다.""" + want = { + "labor": {"daily_wage_krw": "원", "hours_per_day": "시간"}, + "material": {"price_krw": "원"}, + "oil": {"price_krw_per_l": "원/L"}, + "machine": { + "price_thousand_krw": "천원", + "fuel_rate_l_per_hour": "L", + "fuel_price_krw_per_l": "원/L", + "hourly_total_krw": "원", + }, + "rate": {"rate_percent": "%", "base_amount_krw": "원"}, + } + bad = [] + for kind, expected in want.items(): + units = {c["key"]: c["unit"] for c in shown[kind]} + for key, unit in expected.items(): + if units.get(key) != unit: + bad.append(f"{kind}/{key}: {units.get(key)!r} ≠ {unit!r}") + assert not bad, bad diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 671704e0..8e5504f9 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -449,7 +449,7 @@ def test_미결은_미판정으로_표시된다(labels): assert column["name_ko"] == "미판정" -MERGED_KEYS = ("labor", "material", "oil", "rates", "machine") +MERGED_KEYS = ("labor", "material", "oil", "rate", "machine") def test_기초단가_다섯이_다_있다(labels): @@ -462,31 +462,27 @@ def test_기초단가_다섯이_다_있다(labels): assert entry["sources"], entry["key"] -def test_합친_표의_열마다_이름과_고칠수있나가_있다(labels): - bad = [] - for entry in labels["merged_tables"]: - for column in entry["columns"]: - if not column["name_ko"].strip(): - bad.append(f"{entry['key']}/{column['key']} — 이름 없음") - if not isinstance(column.get("editable"), bool): - bad.append(f"{entry['key']}/{column['key']} — editable 없음") +def test_합친_표의_열마다_한글_이름이_있다(labels): + """⚠ 「고칠 수 있나」는 여기서 안 정한다 — 서버 `spec()` 한 곳(판정 두 벌 금지). + 영문으로 떨어지는 열이 있는지는 `test_master_labels_base_prices.py` 가 응답으로 잰다.""" + bad = [ + f"{entry['key']}/{column['key']}" + for entry in labels["merged_tables"] + for column in entry["columns"] + if not column["name_ko"].strip() + ] assert not bad, bad -def test_계산값은_기계에만_있고_못_고친다(labels): - """⭐ 밑값만 고칠 수 있다 — 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없다.""" - derived = { - entry["key"]: [c["key"] for c in entry["columns"] if not c["editable"]] - for entry in labels["merged_tables"] - } - assert derived["machine"] == [ - "hourly_loss_krw", - "hourly_fuel_krw", - "hourly_operator_krw", - "hourly_total_krw", - ] - for key in ("labor", "material", "oil", "rates"): - assert derived[key] == [], f"{key} 에 계산값이 생겼다" +def test_기계만_다른_표의_값을_문다(labels): + """⭐ 기계 시간당 단가는 유가·노임 표를 물어 나온다 — 갱신 차례가 여기서 갈린다.""" + merged = {m["key"]: m for m in labels["merged_tables"]} + machine = {c["key"]: c for c in merged["machine"]["columns"]} + for key in ("fuel_price_krw_per_l", "operator_daily_wage_krw"): + assert key in machine, f"{key} 이름표가 없다" + assert "표" in machine[key].get("from", ""), f"{key} 가 어디서 오는지 안 적혔다" + for key in ("labor", "material", "oil", "rate"): + assert len(merged[key]["sources"]) <= 3, key def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels): @@ -521,7 +517,7 @@ def test_줄_열쇠가_다섯_다_판정돼_있다(labels): # 한 칸으로는 안 되는 둘 — 묶어야 한다. assert len(keys["labor"]["key"]) > 1 assert keys["oil"]["verdict"].startswith("없음") - assert keys["rates"]["verdict"].startswith("없음") + assert keys["rate"]["verdict"].startswith("없음") def test_null_이_제값인_열이_적혀_있다(labels): From 4fedddc4e84b6f70ae2cca643ec40112d7b5745d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 10:52:32 +0900 Subject: [PATCH 25/48] =?UTF-8?q?feat(z01):=20=ED=91=9C=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EC=A4=84=EB=A7=88=EB=8B=A4=20=ED=95=9C=20=EC=A4=84(=EC=B2=AB?= =?UTF-8?q?=20=EC=A4=84=20=E3=80=8C=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=9C?= =?UTF-8?q?=EB=8A=94=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=B6=80?= =?UTF-8?q?=ED=84=B0=E3=80=8D=20=C2=B7=20=EC=9A=94=EC=9C=A8=EC=9D=80=20?= =?UTF-8?q?=EB=B2=95=EC=A0=95=EA=B0=92=20=EA=B2=BD=EA=B3=A0=EA=B9=8C?= =?UTF-8?q?=EC=A7=80)=20=C2=B7=20=EA=B8=80=EC=9E=90=20=EC=86=8D=20**?= =?UTF-8?q?=EA=B5=B5=EA=B2=8C**=20=EB=A5=BC=20=EA=B5=B5=EC=9D=80=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=EC=9C=BC=EB=A1=9C=20=ED=8F=84(=EB=B3=84?= =?UTF-8?q?=ED=91=9C=20=EC=95=88=20=EB=B3=B4=EC=9E=84)=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=9D=BC=20=EA=B5=B5=EC=9D=80=20=EC=B9=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=98=90=EB=A0=B7=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 23 ++++++++++++++--- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 29 ++++++++++++--------- Z01_MasterData/Z01_MasterData_UI_Style.css | 26 ++++++++----------- resources/tester/test_z01_master_cells.py | 30 +++++++++++++++++++++- 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 77a7d5db..3fd34c1c 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -85,14 +85,29 @@ export function outdatedBanner(sources?: TableSource[]): string[] { }); } +/** 알림은 한 줄로도 목록으로도 옴 — 받은 대로 줄 목록으로. */ +export function noticeLines(notice?: string | string[]): string[] { + if (!notice) return []; + return Array.isArray(notice) ? notice.filter(Boolean) : [notice]; +} + +/** 서버 글자의 `**굵게**` 를 조각으로 — 별표가 화면에 글자로 남으면 안 됨. */ +export function boldSegments(text: string): { text: string; bold: boolean }[] { + return text + .split(/\*\*(.+?)\*\*/g) + .map((part, index) => ({ text: part, bold: index % 2 === 1 })) + .filter((part) => part.text !== ""); +} + /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { /** 한 표가 모은 원본마다 한 줄 */ source?: TableSource[]; - /** 서버가 주는 알림 한 줄(요율 — 법이 정한 값) — 화면이 사전을 가지면 두 벌이 됨 */ - notice?: string; - /** 고친 값이 어디에 닿는지 — 새로 만드는 프로젝트부터 쓰임(서버가 줌) */ - scope_notice?: string; + /** + * 표 머리 알림 — 서버가 한 벌로 줌(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지). + * 화면이 사전을 가지면 두 벌이 됨. + */ + notice?: string | string[]; editable?: string[]; /** 열key → 왜 못 고치나 */ locked?: Record; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 0763909c..d09f1747 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -11,10 +11,12 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { t as L } from "@ui/ui_template_locale"; import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { + boldSegments, cellInfo, clampPage, columnTitle, latestOnly, + noticeLines, outdatedBanner, pageCount, parseCellInput, @@ -97,8 +99,7 @@ export function buildRowsView(): RowsView { className: "z01-master__check", children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); - const notice = el("p", { className: "z01-master__notice" }); - const scope = el("p", { className: "z01-master__scope" }); + const notice = el("div", { className: "z01-master__notice" }); const banner = el("div", { className: "z01-master__outdated" }); const detail = el("div", { className: "z01-master__detail" }); const grid = el("div", { className: "z01-master__grid-wrap" }); @@ -127,9 +128,8 @@ export function buildRowsView(): RowsView { className: "z01-master__panel", children: [ el("div", { children: [title, titleKey] }), - scope, - banner, notice, + banner, toolbar, detail, grid, @@ -141,7 +141,6 @@ export function buildRowsView(): RowsView { detail.hidden = true; notice.hidden = true; banner.hidden = true; - scope.hidden = true; function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -191,15 +190,22 @@ export function buildRowsView(): RowsView { function draw(): void { if (!last) return; const meta = last; - // 표 머리 세 줄 — 모두 서버가 주는 글자 그대로(화면이 사전을 가지면 두 벌이 됨). - // ① 고친 값이 닿는 자리 ② 뒤처진 원본(서버 outdated 만 · 날짜 비교 안 함) ③ 그 밖 알림 - scope.textContent = meta.scope_notice ?? ""; - scope.hidden = !meta.scope_notice; + // 표 머리 — 모두 서버 글자 그대로(화면이 사전을 가지면 두 벌이 됨). + // ① 알림(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지) ② 뒤처진 원본(서버 outdated 만) + const notices = noticeLines(meta.notice); + notice.replaceChildren( + ...notices.map((text) => + el("p", { + children: boldSegments(text).map((part) => + part.bold ? el("strong", { text: part.text }) : el("span", { text: part.text }), + ), + }), + ), + ); + notice.hidden = notices.length === 0; const outdated = outdatedBanner(meta.source); banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` }))); banner.hidden = outdated.length === 0; - notice.textContent = meta.notice ?? ""; - notice.hidden = !meta.notice; const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -366,7 +372,6 @@ export function buildRowsView(): RowsView { titleKey.textContent = picked.key; notice.hidden = true; banner.hidden = true; - scope.hidden = true; search.root.hidden = picked.searchable === false; toolbar.hidden = false; pager.hidden = false; diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index c4894ce4..d25b3950 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -125,17 +125,6 @@ font-size: var(--text-body); } -/* 고친 값이 닿는 자리 — 흐리면 「저장이 안 됐다」로 읽힘(2026-09-16 브레인) */ -.z01-master__scope { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-accent); - background: var(--color-mist-violet); - color: var(--color-accent); - font-size: var(--text-body-sm); - font-weight: var(--font-weight-bold); -} - /* 뒤처진 자료 띠 — 받는 자리까지 적힘 · 또렷이 · 원본마다 한 줄 */ .z01-master__outdated p { margin: 0; @@ -151,16 +140,23 @@ font-weight: var(--font-weight-bold); } -/* 요율 알림 — 막지 않고 알리기만 */ +/* 표 머리 알림 — 고친 값이 닿는 자리·법정값 경고. 흐리면 「저장이 안 됐다」로 읽힘(2026-09-16 브레인) */ .z01-master__notice { + display: flex; + flex-direction: column; + gap: var(--spacing-4); margin: 0; padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-warning); - background: var(--color-paper); - color: var(--color-text-body); + border-left: 4px solid var(--color-accent); + background: var(--color-mist-violet); + color: var(--color-accent); font-size: var(--text-body-sm); } +.z01-master__notice p { + margin: 0; +} + .z01-master__toolbar { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 823fc746..13983ba2 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,20 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - outdatedBanner, shownColumns, valueWithUnit, + boldSegments, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 표 수준 알림 — 서버가 한 벌로 목록을 줌(첫 줄 「새로 만드는 프로젝트부터」) · 글자에 **굵게** 가 섞임 +const notices = [ + noticeLines(["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"]), + noticeLines("한 줄짜리"), + noticeLines(undefined), +]; +const bolds = [ + boldSegments("여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임"), + boldSegments("굵게 없음"), +]; + // 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인) // ⚠ 한 표가 원본 여럿을 모은 것이라 출처는 목록으로 옴(노임 = 건설업 + 제조업). const laborSources = [ @@ -155,6 +166,8 @@ writeFileSync(process.argv[2], JSON.stringify({ valueWithUnit("5억 미만", ""), ], banners, + notices, + bolds, })); """ @@ -248,3 +261,18 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: [], [], ] + # 알림은 목록도 한 줄도 받음 · 없으면 빈 목록 + assert got["notices"] == [ + ["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"], + ["한 줄짜리"], + [], + ] + # **…** 는 굵게 조각으로 갈라 보임 — 별표가 글자로 남으면 안 됨 + assert got["bolds"] == [ + [ + {"text": "여기서 고친 값은 ", "bold": False}, + {"text": "새로 만드는 프로젝트부터", "bold": True}, + {"text": " 쓰임", "bold": False}, + ], + [{"text": "굵게 없음", "bold": False}], + ] From 3a61d4c1586688ce2ea5d74ca2eb1343678d4e59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 10:59:36 +0900 Subject: [PATCH 26/48] =?UTF-8?q?feat(z01):=20=EC=97=B4=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EB=88=8C=EB=9F=AC=20=EC=B0=A8=EB=A1=80=20=E2=80=94?= =?UTF-8?q?=20=EB=88=84=EB=A5=B4=EA=B8=B0=20=EC=A0=84=EC=97=94=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=97=86=EC=9D=8C(=EC=9B=90=EB=B3=B8=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80)=20=C2=B7=20=EC=98=A4=EB=A6=84=E2=86=92=EB=82=B4?= =?UTF-8?q?=EB=A6=BC=E2=86=92=EC=9B=90=EB=B3=B8=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=EB=8A=94=20=EC=84=9C=EB=B2=84=20=EB=AA=AB(so?= =?UTF-8?q?rt=C2=B7desc=20=EB=B3=B4=EB=83=84=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EA=B1=B0=EC=A0=88=ED=95=98=EB=A9=B4=20=EB=88=84?= =?UTF-8?q?=EB=A5=B8=20=ED=91=9C=EC=8B=9C=20=EC=A7=80=EC=9B=80)=20=C2=B7?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=EC=9D=80=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EC=A4=80=20sortable=20=EC=97=B4=EB=A7=8C=20=EB=88=8C=EB=A6=BC(?= =?UTF-8?q?=EB=88=8C=EB=A0=80=EB=8A=94=EB=8D=B0=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=EB=A7=89=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 9 ++++++- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 17 ++++++++++++ Z01_MasterData/Z01_MasterData_UI_Rows.ts | 30 ++++++++++++++++++++-- Z01_MasterData/Z01_MasterData_UI_Style.css | 9 +++++++ resources/tester/test_z01_master_cells.py | 18 ++++++++++++- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index e4daa002..7d881ee6 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn, OverrideState, TableMeta } from "./Z01_MasterData_UI_Cells"; +import type { MasterColumn, OverrideState, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -43,6 +43,8 @@ export interface RowsQuery { page: number; size: number; q: string; + /** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */ + sort?: SortOrder | null; } /** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */ @@ -68,6 +70,11 @@ const pageParams = (query: RowsQuery): Record => ({ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise { const params = new URLSearchParams(pageParams(query)); + // 안 누르면 sort 를 안 보냄 — 서버가 원본 차례로 줌(요율·기계는 차례가 뜻을 담음). + if (query.sort) { + params.set("sort", query.sort.key); + params.set("desc", query.sort.desc ? "1" : "0"); + } return request(`/master-data/base-prices/${kind}?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 3fd34c1c..9ca85955 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -85,6 +85,18 @@ export function outdatedBanner(sources?: TableSource[]): string[] { }); } +/** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */ +export interface SortOrder { + key: string; + desc: boolean; +} + +/** 같은 열을 누르면 오름 → 내림 → 원본 차례, 다른 열이면 그 열 오름부터. */ +export function nextSort(current: SortOrder | null, key: string): SortOrder | null { + if (current?.key !== key) return { key, desc: false }; + return current.desc ? null : { key, desc: true }; +} + /** 알림은 한 줄로도 목록으로도 옴 — 받은 대로 줄 목록으로. */ export function noticeLines(notice?: string | string[]): string[] { if (!notice) return []; @@ -108,6 +120,11 @@ export interface TableMeta { * 화면이 사전을 가지면 두 벌이 됨. */ notice?: string | string[]; + /** + * 서버가 세워 줄 수 있는 열 — 이 목록에 있는 제목만 눌림. + * 없으면 제목이 안 눌림(원본 차례) — 눌렀는데 그대로인 화면을 안 만들려고 서버에 물어서 켬. + */ + sortable?: string[]; editable?: string[]; /** 열key → 왜 못 고치나 */ locked?: Record; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index d09f1747..2e8fae42 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -16,6 +16,7 @@ import { clampPage, columnTitle, latestOnly, + nextSort, noticeLines, outdatedBanner, pageCount, @@ -25,6 +26,7 @@ import { valueWithUnit, type CellInfo, type MasterColumn, + type SortOrder, } from "./Z01_MasterData_UI_Cells"; const PAGE_SIZE = 50; @@ -69,7 +71,7 @@ function stateText(info: CellInfo, column: MasterColumn): string { export function buildRowsView(): RowsView { let source: RowsSource | null = null; - const state = { page: 1, q: "" }; + const state = { page: 1, q: "", sort: null as SortOrder | null }; let showHidden = false; let last: MasterRows | null = null; let selected: { index: number; key: string } | null = null; @@ -150,13 +152,24 @@ export function buildRowsView(): RowsView { async function load(): Promise { if (!source) return; + const asked = state.sort; grid.classList.add("is-loading"); let data: MasterRows | undefined; try { - data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q }); + data = await fetchLatest(source, { + page: state.page, + size: PAGE_SIZE, + q: state.q, + sort: state.sort, + }); } catch (error) { grid.classList.remove("is-loading"); showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + // 서버가 그 열로는 못 세운다고 하면(400) 누른 표시를 지움 — 눌렀는데 안 바뀐 것처럼 두지 않음. + if (asked && state.sort === asked) { + state.sort = null; + draw(); + } return; } // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. @@ -184,6 +197,18 @@ export function buildRowsView(): RowsView { if (column.label !== column.key) { th.append(el("span", { className: "z01-master__key", text: column.key })); } + // 차례 — 누르기 전에는 아무 표시도 없음(원본 차례라는 것이 보이게 · 2026-09-16 브레인). + if (meta.sortable?.includes(column.key)) { + th.classList.add("is-sortable"); + if (state.sort?.key === column.key) { + th.append(el("span", { className: "z01-master__sort", text: state.sort.desc ? "▼" : "▲" })); + } + th.addEventListener("click", () => { + state.sort = nextSort(state.sort, column.key); + state.page = 1; + void load(); + }); + } return th; } @@ -368,6 +393,7 @@ export function buildRowsView(): RowsView { function show(picked: RowsSource): void { source = picked; state.page = 1; + state.sort = null; title.textContent = picked.title; titleKey.textContent = picked.key; notice.hidden = true; diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index d25b3950..a8959587 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -235,6 +235,15 @@ margin-left: 0; } +.z01-master__grid th.is-sortable { + cursor: pointer; +} + +.z01-master__sort { + margin-left: var(--spacing-4); + color: var(--color-accent); +} + .z01-master__mark { margin-left: var(--spacing-4); cursor: help; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 13983ba2..26cdc294 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,17 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - boldSegments, noticeLines, outdatedBanner, shownColumns, valueWithUnit, + boldSegments, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터 +const sorts = [ + nextSort(null, "price_krw"), + nextSort({ key: "price_krw", desc: false }, "price_krw"), + nextSort({ key: "price_krw", desc: true }, "price_krw"), + nextSort({ key: "price_krw", desc: true }, "item_code"), +]; + // 표 수준 알림 — 서버가 한 벌로 목록을 줌(첫 줄 「새로 만드는 프로젝트부터」) · 글자에 **굵게** 가 섞임 const notices = [ noticeLines(["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"]), @@ -168,6 +176,7 @@ writeFileSync(process.argv[2], JSON.stringify({ banners, notices, bolds, + sorts, })); """ @@ -276,3 +285,10 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: ], [{"text": "굵게 없음", "bold": False}], ] + # 세 번째 누르면 원본 차례로 — 누르기 전에는 차례 표시가 아예 없어야 함 + assert got["sorts"] == [ + {"key": "price_krw", "desc": False}, + {"key": "price_krw", "desc": True}, + None, + {"key": "item_code", "desc": False}, + ] From 58c40b7c50e61ffa3595f1bef2750f642cfc6f36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:18:55 +0900 Subject: [PATCH 27/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=20=EC=A4=84=20=EC=84=B8=EC=9A=B0=EA=B8=B0?= =?UTF-8?q?=20=E2=80=94=20sort=C2=B7desc=20=EB=A5=BC=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=A0=84=EC=B2=B4=20=EC=A4=84=EB=A1=9C(=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20140=20=EC=AA=BD=EC=9D=B4=EB=9D=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=B4=20=EB=B0=9B=EC=9D=80=2050=20=EC=A4=84?= =?UTF-8?q?=EB=A7=8C=20=EC=84=B8=EC=9A=B0=EB=A9=B4=20=EA=B0=80=EC=A7=9C=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80)=20=C2=B7=20=ED=91=9C=20=EC=88=98=EC=A4=80?= =?UTF-8?q?=20sortable=20=EB=A1=9C=20=EB=88=8C=EB=A6=B4=20=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20=EC=95=8C=EB=A6=BC(=EA=B3=84=EC=82=B0=20=EC=97=B4?= =?UTF-8?q?=EB=8F=84=20=EC=84=B8=EC=9B=80)=20=C2=B7=20=EB=8D=AE=EA=B0=9C?= =?UTF-8?q?=20=EC=96=B9=EC=9D=80=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EB=A5=B8=20=EB=92=A4=EC=97=90=20=EC=84=B8=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=B9=B8=EC=9D=80=20=EC=96=B4=EB=8A=90=20?= =?UTF-8?q?=EC=AA=BD=EC=9D=B4=EB=93=A0=20=EB=81=9D=20=C2=B7=20=EB=AA=A8?= =?UTF-8?q?=EB=A5=B4=EB=8A=94=20=EC=97=B4=EC=9D=80=20400(=EC=A1=B0?= =?UTF-8?q?=EC=9A=A9=ED=9E=88=20=EC=9B=90=EB=B3=B8=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=20=EB=8F=8C=EC=95=84=EA=B0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 36 +++++++++++++- Z01_MasterData/Z01_MasterData_Router.py | 14 ++++-- resources/tester/test_z01_base_prices.py | 52 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 0b5c2d8e..c5a74fbd 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -278,6 +278,29 @@ def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: return list(keys) +class SortError(ValueError): + """모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④).""" + + +def _order_key(value: Any) -> tuple: + """섞인 칸도 세움 — 수 먼저, 그 뒤 글자.""" + if isinstance(value, bool): + return (1, str(value)) + if isinstance(value, (int, float)): + return (0, float(value)) + if isinstance(value, (dict, list)): + return (1, json.dumps(value, ensure_ascii=False, default=str)) + return (1, str(value)) + + +def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[str, Any]]: + """⚠ 빈 칸은 오름·내림 **어느 쪽이든 끝** — 미공표 노임 14 · 손료계수 없는 기계 226 이 + 첫 쪽을 통째로 채우면 표를 못 씀(브레인 ②).""" + filled = [r for r in rows if r.get(column) not in (None, "")] + empty = [r for r in rows if r.get(column) in (None, "")] + return sorted(filled, key=lambda r: _order_key(r[column]), reverse=desc) + empty + + def notice(kind: str) -> list[str]: """표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③).""" return [_SCOPE_NOTICE] + ([_RATE_NOTICE] if kind == "rate" else []) @@ -349,7 +372,12 @@ def public(row: dict[str, Any]) -> dict[str, Any]: def table( - kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" + kind: str, + page: int = 1, + size: int = tables.DEFAULT_PAGE_SIZE, + q: str = "", + sort: str = "", + desc: bool = False, ) -> dict[str, Any]: all_rows = rows(kind) columns = _columns(kind, all_rows) @@ -363,10 +391,16 @@ def table( if needle else all_rows ) + if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③) + if sort not in columns: + raise SortError(f"없는 열로 세울 수 없음: {sort}") + hits = _sorted(hits, sort, desc) size = max(1, min(size, tables.MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size return { "columns": [column_meta(kind, c) for c in columns], + # 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림 + "sortable": list(columns), "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index 31b9e53c..c9296fe9 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -2,7 +2,7 @@ GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) - GET /api/master-data/base-prices/{kind}?page=&size=&q= 기초단가 한 표(labor|machine|material|oil|rate) + GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 기초단가 한 표(labor|machine|material|oil|rate) PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. @@ -50,9 +50,17 @@ def _kind(kind: str) -> str: @router.get("/base-prices/{kind}") def get_base_prices( - kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" + kind: str, + page: int = 1, + size: int = tables.DEFAULT_PAGE_SIZE, + q: str = "", + sort: str = "", + desc: int = 0, ) -> dict: - return base_prices.table(_kind(kind), page=page, size=size, q=q) + try: + return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc)) + except base_prices.SortError as error: + raise HTTPException(status_code=400, detail=str(error)) from error @router.put("/base-prices/{kind}/{row_id:path}") diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 22c58a84..e8324e3e 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -496,3 +496,55 @@ def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], ( kind ) + + +def _sorted_rows(client: TestClient, kind: str, column: str, desc: int = 0, **params) -> list: + table = _get(client, kind, sort=column, desc=desc, size=500, **params) + return [r.get(column) for r in table["rows"]] + + +def test_차례_세우기는_서버가_전체_줄로(client: TestClient) -> None: + """브레인 — 자재 140 쪽이라 화면이 받은 50 줄만 세우면 1 쪽 안에서만 맞는 가짜 차례.""" + table = _get(client, "material", size=1) + assert set(table["sortable"]) == {c["key"] for c in table["columns"]} + first = _get(client, "material", sort="price_krw", size=1) + last = _get(client, "material", sort="price_krw", desc=1, size=1) + assert first["total"] == last["total"] == 6999 + assert first["rows"][0]["price_krw"] < last["rows"][0]["price_krw"] + page2 = _get(client, "material", sort="price_krw", size=50, page=2)["rows"] + assert first["rows"][0]["price_krw"] <= page2[0]["price_krw"] # 쪽을 넘어도 이어짐 + values = _sorted_rows(client, "material", "price_krw") + assert values == sorted(values) + + +def test_거른_뒤에_세움_모르는_열은_거절(client: TestClient) -> None: + hits = _get(client, "material", q="육각볼트", sort="price_krw", desc=1, size=500) + values = [r["price_krw"] for r in hits["rows"]] + assert values == sorted(values, reverse=True) and hits["total"] == len(values) + assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in hits["rows"]) + bad = client.get("/api/master-data/base-prices/material", params={"sort": "없는열"}) + assert bad.status_code == 400 and "없는열" in bad.text # 조용히 원본 차례로 안 돌아감 + + +def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None: + """미공표 노임 14 · 손료계수 없는 기계 226 이 첫 쪽을 채우면 표를 못 씀.""" + for desc in (0, 1): + wages = _sorted_rows(client, "labor", "daily_wage_krw", desc) + filled = [v for v in wages if v is not None] + assert wages[: len(filled)] == sorted(filled, reverse=bool(desc)) + assert wages[len(filled) :] == [None] * (len(wages) - len(filled)) and len(filled) < len( + wages + ) + hourly = _sorted_rows(client, "machine", "hourly_total_krw", 1) + assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐 + + +def test_덮개_얹은_값으로_세움(client: TestClient) -> None: + _put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000}) + top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0] + assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000 + + +def test_sort_가_없으면_원본_차례(client: TestClient) -> None: + assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae" + assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" From fe10ef86abd07f33724282716d3569f5317a096b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:19:55 +0900 Subject: [PATCH 28/48] =?UTF-8?q?feat(z01):=20=EB=8D=AE=EA=B0=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=84=B7=EC=A7=B8=20meaning=5Fchanged=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=99=EC=9D=80=20=EB=B2=88=ED=98=B8=EC=97=90=20?= =?UTF-8?q?=EB=94=B4=20=EB=AC=BC=EA=B1=B4(=EB=AC=B8=ED=99=94=EC=9E=AC?= =?UTF-8?q?=EB=AA=A9=EA=B3=B5=20=E2=86=92=20=ED=95=9C=EC=8B=9D=EB=AA=A9?= =?UTF-8?q?=EA=B3=B5)=20=C2=B7=20=EA=B0=92=20=EC=96=B4=EA=B8=8B=EB=82=A8?= =?UTF-8?q?=EB=B3=B4=EB=8B=A4=20=EC=84=B8=EA=B2=8C(=EC=A4=84=20=EC=A7=84?= =?UTF-8?q?=ED=95=9C=20=EB=B6=89=EC=9D=8C=20=C2=B7=20=EC=B9=B8=20=EA=BD=89?= =?UTF-8?q?=20=EC=B0=AC=20=EB=B6=89=EC=9D=8C=20=C2=B7=20=E2=9B=94)=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EC=9D=B4=EB=A6=84/=EC=A7=80=EA=B8=88=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=82=98=EB=9E=80=ED=9E=88=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=83=88=20=EC=9B=90=EB=B3=B8=20=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EA=B8=B0=E3=80=8D=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=E3=80=8C=EA=B3=A0=EC=B9=9C=20=EA=B0=92=20=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=A7=80=EA=B8=88=20=EC=A4=84=20=EA=B0=92=20?= =?UTF-8?q?=E2=97=8B=20=EC=93=B0=EA=B8=B0=E3=80=8D=20+=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=EB=8D=94=20=EB=AC=BC=EC=9D=8C=20=C2=B7=20=EB=AA=A8?= =?UTF-8?q?=EC=95=84=20=EB=B3=B4=EA=B8=B0=20=EC=9D=B4=EB=A6=84=20=E3=80=8C?= =?UTF-8?q?=E2=9B=94=20=EB=9C=BB=20=EB=B0=94=EB=80=9C=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 25 +++++++++--- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 47 +++++++++++++++++----- Z01_MasterData/Z01_MasterData_UI_Side.ts | 7 ++-- Z01_MasterData/Z01_MasterData_UI_Style.css | 14 ++++++- resources/tester/test_z01_master_cells.py | 21 +++++++++- ui_template/ui_template_locale_b3.ts | 16 ++++++++ 6 files changed, 109 insertions(+), 21 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 9ca85955..b4e9b850 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -132,8 +132,11 @@ export interface TableMeta { formula?: Record; } -/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */ -export type OverrideState = "edited" | "source_changed" | "orphan"; +/** + * 덮개 판정 낱말 넷 — 서버가 줌 · 이것만 씀(2026-09-15~16 브레인). + * meaning_changed = 번호는 그대로인데 줄의 뜻이 바뀜(문화재목공 → 한식목공) — 같은 코드에 딴 물건. + */ +export type OverrideState = "edited" | "source_changed" | "meaning_changed" | "orphan"; export interface CellInfo { editable: boolean; @@ -143,8 +146,12 @@ export interface CellInfo { original?: unknown; /** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */ state: OverrideState | "missing" | null; - /** source_changed 칸만 — 지금 원본 값 */ + /** source_changed·meaning_changed 칸만 — 지금 원본 값 */ current?: unknown; + /** meaning_changed 칸만 — 고칠 때 줄 이름 */ + was?: string; + /** meaning_changed 칸만 — 지금 줄 이름 */ + now?: string; rule: string | null; /** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */ filled: string | null; @@ -153,7 +160,11 @@ export interface CellInfo { /** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */ export function cellInfo(meta: TableMeta, row: Record, key: string): CellInfo { const overrides = row["@overrides"] as - Record | undefined; + | Record< + string, + { original: unknown; current?: unknown; state?: OverrideState; was?: string; now?: string } + > + | undefined; const filled = row["@formula"] as Record | undefined; const override = overrides?.[key]; // 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인). @@ -164,7 +175,11 @@ export function cellInfo(meta: TableMeta, row: Record, key: str overridden: override !== undefined, ...(override ? { original: override.original } : {}), state, - ...(state === "source_changed" ? { current: override?.current } : {}), + ...(state === "source_changed" || state === "meaning_changed" + ? { current: override?.current } + : {}), + ...(override?.was !== undefined ? { was: override.was } : {}), + ...(override?.now !== undefined ? { now: override.now } : {}), rule: meta.formula?.[key] ?? null, filled: filled?.[key] ?? null, }; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 2e8fae42..8cc9b8c9 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -7,7 +7,13 @@ * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). * ========================================================================== */ -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { @@ -58,6 +64,9 @@ function fill(key: Parameters[0], value: string): string { function stateText(info: CellInfo, column: MasterColumn): string { const original = valueWithUnit(info.original, column.unit); switch (info.state) { + case "meaning_changed": + // 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히. + return `${L("Z01_MasterData_State_MeaningChanged")} — ${fill("Z01_MasterData_State_Was", info.was ?? "?")} / ${fill("Z01_MasterData_State_Now", info.now ?? "?")} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`; case "source_changed": return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; case "orphan": @@ -242,10 +251,11 @@ export function buildRowsView(): RowsView { const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); if (info.state) { // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). - td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`); + td.classList.add("is-overridden", `is-${info.state.replaceAll("_", "-")}`); if (info.state !== "edited") { - td.textContent = `⚠ ${texts[i]}`; - tr.classList.add("has-warning"); + // 뜻 바뀜은 값 어긋남보다 나쁨 — 표시부터 더 세게(2026-09-16 브레인). + td.textContent = `${info.state === "meaning_changed" ? "⛔" : "⚠"} ${texts[i]}`; + tr.classList.add(info.state === "meaning_changed" ? "has-danger" : "has-warning"); } td.title = stateText(info, column); } @@ -310,18 +320,21 @@ export function buildRowsView(): RowsView { if (info.state !== "edited") stateLine.classList.add("is-warning"); lines.push(stateLine); // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). + // 뜻이 바뀐 줄에서 「새 원본 값으로 받기」는 위험 — 글자를 가르고 한 번 더 물음(브레인 ③). const target = - info.state === "source_changed" - ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) - : info.state === "orphan" - ? null - : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); + info.state === "meaning_changed" + ? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit)) + : info.state === "source_changed" + ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) + : info.state === "orphan" + ? null + : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); if (target && source?.save) { lines.push( createButton({ label: target, - variant: "ghost", - onClick: () => void commit(index, column, null), + variant: info.state === "meaning_changed" ? "danger" : "ghost", + onClick: () => void drop(index, column, info), }), ); } @@ -365,6 +378,18 @@ export function buildRowsView(): RowsView { input.addEventListener("blur", () => finish(true)); } + /** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */ + async function drop(index: number, column: MasterColumn, info: CellInfo): Promise { + if (info.state === "meaning_changed") { + const ok = await showConfirmDialog( + `${fill("Z01_MasterData_State_Was", info.was ?? "?")} / ${fill("Z01_MasterData_State_Now", info.now ?? "?")}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`, + L("Z01_MasterData_Edit_DropConfirm"), + ); + if (!ok) return; + } + await commit(index, column, null); + } + /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ async function commit(index: number, column: MasterColumn, value: unknown): Promise { const from = source; diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 56111cb7..c50d6fed 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -20,7 +20,7 @@ import { saveBasePrice, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; -import { valueWithUnit } from "./Z01_MasterData_UI_Cells"; +import { valueWithUnit, type OverrideState } from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { @@ -118,11 +118,12 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme return panel; } -const STATE_LABEL = { +const STATE_LABEL: Record[0]> = { + meaning_changed: "Z01_MasterData_List_MeaningChanged", source_changed: "Z01_MasterData_List_SourceChanged", orphan: "Z01_MasterData_List_Orphan", edited: "Z01_MasterData_List_Edited", -} as const; +}; /** * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index a8959587..b62321c4 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -271,10 +271,22 @@ background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas)); } -.z01-master__grid tr.has-warning td:first-child { +.z01-master__grid tr.has-warning td:first-child, +.z01-master__grid tr.has-danger td:first-child { box-shadow: inset 4px 0 0 var(--color-danger); } +/* 뜻 바뀜 — 값 어긋남보다 나쁨(같은 번호에 딴 물건). 줄째 더 진하게. */ +.z01-master__grid tr.has-danger td { + background: color-mix(in srgb, var(--color-danger) 18%, var(--color-canvas)); +} + +.z01-master__grid tr td.is-meaning-changed { + background: var(--color-danger); + color: var(--color-primary-text); + font-weight: var(--font-weight-bold); +} + /* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */ .z01-master__grid tr td.is-source-changed, .z01-master__grid tr td.is-orphan, diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 26cdc294..a88ad823 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -111,6 +111,21 @@ const withState = (state, current) => ({ const changedRow = withState("source_changed", 250000); const sameRow = withState("edited", 270000); const orphanRow = withState("orphan", undefined); +// 뜻 바뀜 — 번호는 그대로인데 줄이 딴 물건이 됨(노임 3001 문화재목공 → 한식목공) +const meaningRow = { + "@id": "labor_const/3001", + daily_wage_krw: 300000, + "@overrides": { + daily_wage_krw: { + original: 250000, + value: 300000, + current: 260000, + state: "meaning_changed", + was: "문화재목공", + now: "한식목공", + }, + }, +}; const wait = (ms, value, fail) => new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms)); @@ -156,6 +171,7 @@ writeFileSync(process.argv[2], JSON.stringify({ cellInfo(meta, changedRow, "daily_wage_krw"), cellInfo(meta, sameRow, "daily_wage_krw"), cellInfo(meta, orphanRow, "daily_wage_krw"), + cellInfo(meta, meaningRow, "daily_wage_krw"), ], parsed: [ parseCellInput("12,340", 100), @@ -234,7 +250,7 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["raceError"] == [None, "new"] assert got["latestError"] == "thrown boom" # 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식 - wage, hourly, plain, changed, same, orphan = got["info"] + wage, hourly, plain, changed, same, orphan, meaning = got["info"] # state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그) assert wage == { "editable": True, @@ -249,6 +265,9 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert changed["state"] == "source_changed" and changed["current"] == 250000 assert same["state"] == "edited" and "current" not in same assert orphan["state"] == "orphan" + # 뜻 바뀜 — 옛 이름·지금 이름을 나란히 보여야 사람이 딴 물건인지 가림 + assert meaning["state"] == "meaning_changed" and meaning["current"] == 260000 + assert meaning["was"] == "문화재목공" and meaning["now"] == "한식목공" assert hourly["state"] is None and plain["state"] is None assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴" assert hourly["overridden"] is False and "original" not in hourly diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 5b7bfdc9..4adc3605 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -171,6 +171,22 @@ export const ui_locales_b3 = { "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", "⚠ Source changed after edit — edited value still used · new source {value}", ], + Z01_MasterData_State_MeaningChanged: [ + "⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)", + "⛔ Edited — the row now means something else (same code, different item)", + ], + Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"], + Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"], + Z01_MasterData_Edit_DropOnMeaning: [ + "고친 값 버리고 지금 줄 값 {value} 쓰기", + "Drop the edit and use the current row value {value}", + ], + Z01_MasterData_Edit_MeaningConfirm: [ + "줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?", + "This row now means something else. Dropping the edit uses the current row's value. Continue?", + ], + Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"], + Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"], Z01_MasterData_State_Orphan: [ "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", "⚠ Orphan — the row disappeared on update", From aa4270038251a81f29c80011382a891f21ce8642 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:23:01 +0900 Subject: [PATCH 29/48] =?UTF-8?q?fix(z01):=20=EB=9C=BB=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EC=A4=84=20=EC=9D=B4=EB=A6=84=EC=9D=B4=20=EC=95=88?= =?UTF-8?q?=20=EC=98=A4=EB=A9=B4=20=EB=AC=BC=EC=9D=8C=ED=91=9C=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=E3=80=8C=E2=9B=94=20=EC=98=9B=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=C2=B7=EC=A7=80=EA=B8=88=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EA=B0=80=20=EC=95=88=20=EC=A4=8C=20=E2=80=94?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=E3=80=8D=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=ED=95=A9=EC=B3=90=20=EC=A4=80=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=B3=B4=EC=9E=84(?= =?UTF-8?q?=EC=AA=BC=EA=B0=9C=EC=A7=80=EB=8F=84=20=EC=9E=87=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=95=8A=EC=9D=8C=20=C2=B7=20=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 14 ++++++++++++-- ui_template/ui_template_locale_b3.ts | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 8cc9b8c9..203e52ab 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -60,13 +60,23 @@ function fill(key: Parameters[0], value: string): string { return L(key).replace("{value}", value); } +/** + * 뜻이 바뀐 줄의 옛 이름·지금 이름 — 서버가 합쳐 한 줄로 줌(자재는 품명·규격·인도조건이 이어 붙어 옴). + * 화면은 쪼개거나 다시 합치지 않음. 안 오면 버그로 드러냄. + */ +function meaningNames(info: CellInfo): string { + if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing"); + return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`; +} + /** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ function stateText(info: CellInfo, column: MasterColumn): string { const original = valueWithUnit(info.original, column.unit); switch (info.state) { case "meaning_changed": // 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히. - return `${L("Z01_MasterData_State_MeaningChanged")} — ${fill("Z01_MasterData_State_Was", info.was ?? "?")} / ${fill("Z01_MasterData_State_Now", info.now ?? "?")} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`; + // ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김. + return `${L("Z01_MasterData_State_MeaningChanged")} — ${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`; case "source_changed": return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; case "orphan": @@ -382,7 +392,7 @@ export function buildRowsView(): RowsView { async function drop(index: number, column: MasterColumn, info: CellInfo): Promise { if (info.state === "meaning_changed") { const ok = await showConfirmDialog( - `${fill("Z01_MasterData_State_Was", info.was ?? "?")} / ${fill("Z01_MasterData_State_Now", info.now ?? "?")}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`, + `${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`, L("Z01_MasterData_Edit_DropConfirm"), ); if (!ok) return; diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 4adc3605..662fc14c 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -175,6 +175,10 @@ export const ui_locales_b3 = { "⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)", "⛔ Edited — the row now means something else (same code, different item)", ], + Z01_MasterData_State_MeaningMissing: [ + "⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그", + "⛔ Server sent no before/after row names — bug", + ], Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"], Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"], Z01_MasterData_Edit_DropOnMeaning: [ From 85743ee754c8b162fd56180cf154486b2b0e224a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:28:10 +0900 Subject: [PATCH 30/48] =?UTF-8?q?feat(z01):=20=EB=8D=AE=EA=B0=9C=EC=97=90?= =?UTF-8?q?=20=EB=84=B7=EC=A7=B8=20=EB=82=B1=EB=A7=90=20meaning=5Fchanged?= =?UTF-8?q?=20=E2=80=94=20=EA=B3=A0=EC=B9=A0=20=EB=95=8C=20=EA=B7=B8=20?= =?UTF-8?q?=EC=A4=84=EC=9D=98=20=EA=B0=92=20=EC=95=84=EB=8B=8C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EC=B9=B8(=EC=9D=B4=EB=A6=84=C2=B7=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=C2=B7=EB=8B=A8=EC=9C=84=C2=B7=EC=9D=B8=EB=8F=84?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4)=EC=9D=84=20=ED=95=A8=EA=BB=98=20=EC=A0=81?= =?UTF-8?q?=EA=B3=A0=20=EA=B0=B1=EC=8B=A0=20=EB=92=A4=20=EB=8B=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=A9=B4=20=E3=80=8C=EC=A4=84=EC=9D=98=20=EB=9C=BB?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=94=EB=80=9C=E3=80=8D(=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EB=8B=A4=EB=A5=B8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=E2=80=94=20=EB=85=B8=EC=9E=84=20=EA=B5=AD=EA=B0=80?= =?UTF-8?q?=EC=9C=A0=EC=82=B0=EC=A7=81=EC=A2=85=20=EA=B0=9C=ED=8E=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EC=9E=AC=20KS=20=EA=B0=9C=EC=A0=95)=20=C2=B7?= =?UTF-8?q?=20was/now=20=EB=8A=94=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=EB=A1=9C=20=ED=95=A9=EC=B3=90=20=EB=83=84?= =?UTF-8?q?(=EC=9D=B4=EB=A6=84=20=EC=B9=B8=EC=9D=B4=20=EC=97=AC=EB=9F=BF?= =?UTF-8?q?=EC=9D=B8=20=EC=9E=90=EB=A3=8C=EA=B0=80=20=EC=9E=88=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=B4=20=ED=95=A9=EC=B9=98=EB=A9=B4=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=91=90=20=EB=B2=8C)=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=ED=8C=90=C2=B7=EA=B3=B5?= =?UTF-8?q?=ED=91=9C=20=EB=82=A0=EC=A7=9C=EB=8A=94=20=EB=9C=BB=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=BA=8C(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 20 +++++++- Z01_MasterData/Z01_MasterData_Overrides.py | 54 +++++++++++++++++---- resources/tester/test_z01_base_prices.py | 53 ++++++++++++++++++++ 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index c5a74fbd..615730c1 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -366,6 +366,24 @@ def column_meta(kind: str, key: str) -> dict[str, Any]: } +#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨 +_NOT_MEANING = ("effective_date", "date", "notice_datetime") + + +def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, Any]: + """그 줄의 **값이 아닌 글자 칸**(이름·규격·단위·인도조건 …) — 이름표가 숨김 아니라 한 열만. + + 갱신으로 같은 코드에 다른 기준이 붙으면(노임 국가유산직종 개편 · 자재 KS 개정) 값만으로는 못 잡음. + """ + out = {} + for key, value in row.items(): + if key.startswith("@") or key in editable or key in _NOT_MEANING or key.endswith("_date"): + continue + if isinstance(value, str) and value.strip() and not column_meta(kind, key)["hidden"]: + out[key] = value + return out + + def public(row: dict[str, Any]) -> dict[str, Any]: """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" return {k: v for k, v in row.items() if k != "@source"} @@ -422,7 +440,7 @@ def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, problem = overrides.check_value(value) if problem: return 400, {"message": f"{column}: {problem}"} - overrides.write(kind, base, values, by) + overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"])) return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) diff --git a/Z01_MasterData/Z01_MasterData_Overrides.py b/Z01_MasterData/Z01_MasterData_Overrides.py index 5beeb6db..9cdc5a8e 100644 --- a/Z01_MasterData/Z01_MasterData_Overrides.py +++ b/Z01_MasterData/Z01_MasterData_Overrides.py @@ -25,7 +25,10 @@ from common_util.common_util_json import atomic_write_json OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override" EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan" -STATE_ORDER = (SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 확인할 것이 맨 위 +#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격). +#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16). +MEANING_CHANGED = "meaning_changed" +STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위 _LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요 @@ -61,9 +64,33 @@ def check_value(value: Any) -> str | None: return None -def mark(original: Any, value: Any, current: Any) -> dict[str, Any]: - state = EDITED if same(original, current) else SOURCE_CHANGED - return {"original": original, "value": value, "state": state, "current": current} +#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌. +_JOIN = " · " + + +def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]: + """(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘.""" + stored = entry.get("meaning") or {} + keys = [k for k in stored if row is not None and k in row] + if not keys or all(row[k] == stored[k] for k in keys): + return "", "" + return ( + _JOIN.join(str(stored[k]) for k in keys), + _JOIN.join(str(row[k]) for k in keys), + ) + + +def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]: + original, value = entry.get("original"), entry.get("value") + was, now = meaning_line(entry, row) + if was: + state = MEANING_CHANGED + else: + state = EDITED if same(original, current) else SOURCE_CHANGED + out = {"original": original, "value": value, "state": state, "current": current} + if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감 + out["was"], out["now"] = was, now + return out def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -80,7 +107,7 @@ def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: for column, entry in entries.items(): if column not in row: continue # 주인 없는 칸 — 모아 보기에만 - marks[column] = mark(entry.get("original"), entry.get("value"), row[column]) + marks[column] = mark(entry, row[column], row) row[column] = entry.get("value") if marks: row["@overrides"] = marks @@ -88,7 +115,13 @@ def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return out -def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) -> None: +def write( + kind: str, + base_row: dict[str, Any], + values: dict[str, Any], + by: Any, + meaning: dict[str, Any] | None = None, +) -> None: """고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림.""" row_id = base_row["@id"] with _LOCK: @@ -103,6 +136,8 @@ def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) "value": value, "original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed "source": base_row.get("@source", ""), + # 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음 + "meaning": meaning or {}, "at": datetime.now().astimezone().isoformat(timespec="seconds"), "by": by, } @@ -148,10 +183,10 @@ def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: row = by_id.get(row_id) for column, entry in entries.items(): if row is None or column not in row: - current, state = None, ORPHAN + current, marked = None, {"state": ORPHAN} else: current = row[column] - state = mark(entry.get("original"), entry.get("value"), current)["state"] + marked = mark(entry, current, row) out.append( { "kind": kind, @@ -160,7 +195,8 @@ def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: "original": entry.get("original"), "current": current, "value": entry.get("value"), - "state": state, + "state": marked["state"], + **({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}), } ) return out diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index e8324e3e..c1648c95 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -548,3 +548,56 @@ def test_덮개_얹은_값으로_세움(client: TestClient) -> None: def test_sort_가_없으면_원본_차례(client: TestClient) -> None: assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae" assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" + + +def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None: + """브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격). + + 값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌. + """ + saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json() + assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED + entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"] + assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만 + assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음 + assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨 + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}) + oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"] + assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"] + entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름" + (store / "labor.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8" + ) + mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"] + assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜 + # 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌) + assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published" + assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published" + listed = client.get("/api/master-data/overrides").json() + top = listed["items"][0] + assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게 + assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘 + assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED + assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED) + + +def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None: + _put(client, "machine", "0101-0007", {"price_thousand_krw": 80000}) + meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][ + "0101-0007" + ]["price_thousand_krw"]["meaning"] + assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7" + assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")] + store.mkdir( + exist_ok=True + ) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음) + (store / "material.json").write_text( + json.dumps( + {"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}} + ), + encoding="utf-8", + ) + assert ( + _row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED + ) From 0731f03f2faffc5906fdda4136d83c89e657d099 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:32:12 +0900 Subject: [PATCH 31/48] docs(cost): record base-data update history --- .../05_원가정보/원가_기초데이터_원천.md | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md b/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md index 1fb8995e..31348293 100644 --- a/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md +++ b/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md @@ -8,8 +8,14 @@ sources: - doc: ESTX 정밀분석 문서 11건 (타 AI 작성 → Claude 독립 재검증 2026-08-14) version: 2026-08-13 loc: original/원가계산/다산소프트/ESTX_*.md + - doc: Aislo 기초단가 자료 출처표 + version: 2026-09-15 + loc: resources/data_master_sources/sources_2026-09-15.json + - doc: 기초단가 갱신 이력 조사 (Antigravity 조사 → 브레인 전달) + version: 2026-09-16 + loc: 본문 §4.1 status: verified -last_updated: 2026-08-15 +last_updated: 2026-09-16 --- 관련 문서: [원가계산_체계](원가계산_체계.md) · [제비율](제비율.md) · [품셈_적용기준](../04_수량분석정보/품셈_적용기준.md) · [미결 No.17](../../../03_미결_및_확인사항.md) 사용법 문서(본 문서에서 파생): [노임단가_적용](노임단가_적용.md) · [자재단가_적용](자재단가_적용.md) · [기계경비_산정](기계경비_산정.md) · [건설품셈_활용](../04_수량분석정보/건설품셈_활용.md) @@ -118,6 +124,37 @@ last_updated: 2026-08-15 > [구현] 내역서 산출 흐름과의 연결 (미결 No.17 대상표 기준): 환율및기초자료 표 = ECOS+오피넷 값층 → 중기목록표/중기사용료 = 품셈 기계손료 산식 × 기초값 → 단가산출근거·일위대가 = 품셈(원단위) × 노임·자재 단가 → 재료비·노무비·경비 집계표. 값층 파일에는 **기준일·출처 스탬프 필수**, 내역서에 원천·기준일 명기(§1 관행). 값층 스키마·저장 위치는 개발 단계 협의. +### 4.1 기초단가 다섯의 출처·주기·갱신 이력 (2026-09-16) + +이 절은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)와 Antigravity 조사 결과(2026-09-16 브레인 전달)를 함께 기록한 것이다. 조사에서 확인하지 못한 입수 주소는 임의로 보충하지 않았다. + +| 구분 | 공표·작성 기관 / 원천 | 통상 갱신 주기 | 우리 보유 판 / 최신 확인 판 | +|---|---|---|---| +| 노임 — 건설 | 대한건설협회 건설업 시중노임단가 | 연 2회, **1월 1일·9월 1일** | 우리 `2026-01-01` / 최신 `2026-09-01` — **한 판 뒤처짐** | +| 노임 — 제조 | 중소기업중앙회 중소제조업 직종별 임금 | 반기 | 우리 `2026-07-01` / 최신 판 미확인 | +| 기계 | 국토교통부 건설공사 표준품셈 제8장 건설기계 | 연간 | 우리·최신 `2026-01-01` | +| 자재 | 조달청 관급자재·시설공통자재 | 반기 + 수시 | 우리 `2026-08-14` / 수시 자료라 단일 최신일 미확인 | +| 유가 | 한국석유공사 오피넷 | 주간·일별 | 전국 `2026-08-14`, 시도별 `2026-09-09` / 고정 최신일 없음 | +| 요율 | 조달청 토목공사 원가계산 제비율 적용기준 및 개별 법령·고시 | 연간 + 법령·고시 개정 시 수시 | 우리·최신 확인 `2026-04-13` | + +- 건설 노임 입수처: **대한건설협회 `cak.or.kr` → [지원·사업] → [건설적산기준] → [건설임금]**. 현재 프로그램 자료 [`labor_const_2026-01-01.json`](../../../../data_cost_input_value/labor_const_2026-01-01.json)은 2026년 9월 1일 판을 아직 반영하지 않았다. +- 기계·유가·요율의 판정 기준과 파일별 기준일은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)를 따른다. 제조 노임의 입수 주소와 최신 판, 조달 자재의 단일 최신일은 **근거 미확인**이다. + +#### 갱신할 때 행 구성이 바뀌는 범위 + +| 데이터 | 확인된 변경 이력·규모 | 갱신 시 주의 | +|---|---|---| +| 건설 노임 직종 | 2010년 **145→117**(28직종 통폐합), 2018년 **117→123**, 2020-09-01 **123→127**, 2025-01-01 **127→132** | 직종 코드를 고정 목록으로 보지 말고 신설·통폐합·명칭 변경을 함께 대조한다. | +| 자재 | 반기마다 대략 **100~300개(2~5%) 퇴출**, **수십~수백 개 신설** | 물품분류번호만으로 기존 행을 덮어쓰지 말고 규격·단위·인도조건까지 비교한다. 수치는 조사 관측 범위이며 매 판의 고정값은 아니다. | +| 기계 | 기종 신설·삭제가 잦다. 현재 취득가격 **613행** 중 손료계수가 연결되는 것은 **387행**이다. | 취득가격 행과 손료계수 행을 같은 목록으로 간주하지 않는다. 613/387은 [`labels_2026-01-01.json`](../../../../data_master_labels/labels_2026-01-01.json)의 현행 데이터 전수검사 값이다. | + +#### 번호가 같아도 행의 뜻이 달라지는 사례 + +- **노임 코드 3001~3018**: 국가유산기본법 체계 전환(2024~2025)에 따라 문화재직종에서 국가유산직종으로 명칭·뜻이 바뀌었다. 예: `문화재목공 → 한식목공`. 코드가 같다는 이유만으로 구판 값과 신판 값을 같은 직종으로 자동 승계하면 안 된다. +- **자재 물품분류번호**: 번호를 유지한 채 KS 개정 등에 따라 철근 강종(`SD300/SD400/SD500`), 레미콘 기준, 인도조건(`현장하차도 ↔ 공장상차도`), 포장단위가 달라질 수 있다. 갱신 판정 키에는 번호뿐 아니라 **품명·규격·단위·인도조건**을 포함한다. + +> [운영] 판 교체는 단가만 바꾸는 작업이 아니다. 행의 신설·삭제와 같은 번호의 의미 변경을 별도 목록으로 남기고, 의미가 달라진 행은 자동 연결하지 않는다. 위 사례는 Antigravity가 확인해 전달한 조사 결과이며, 그 밖의 변경 사례는 **근거 미확인**으로 둔다. + ## 5. 실무 임도 내역서 자재 실측 (2026-08-14 — 유료지 필요성 판단 자료) 울진 간선임도(2024, 기번3) 내역 xlsx의 재료비목록표·자재단가대비표 전수 관측. 영월(기번6)도 동일 시트 구조 확인 (STmate 시트 구성 = 미결 No.17 대상표와 일치). From c58c38142cac990531b608268f0e26c7478f97fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:57:21 +0900 Subject: [PATCH 32/48] =?UTF-8?q?fix(z01):=20=EC=9E=90=EC=9E=AC=200?= =?UTF-8?q?=EC=9B=90=2070=20=EC=A4=84=20=E2=80=94=20=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EC=8B=A4=EB=A6=B0=20=EA=B3=B5=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B3=A0=20=EC=95=9E=20=EC=A0=9C?= =?UTF-8?q?=EA=B0=92=20=EC=B1=84=ED=83=9D=20=C2=B7=20=EC=82=AC=EC=9C=A0=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭 — 고르기 규칙이 「물품코드마다 가장 나중 공시」인데 값이 안 실린 공시(`prce=0`)가 옛 제값을 덮음. 각형강관 25*25*1.4t `1,460 → 1,500 → 0`. 「가격이 0」이 아니라 **그 공시에 값이 안 실린 것**임 - 새 규칙 `_build_mat_price.py` — **원천 스냅숏은 읽기만** · 마스터 0원 줄만 손댐 · 6,999 중 **70 줄만 바뀌고 나머지 6,929 줄은 한 글자도 안 바뀜**(줄 수 그대로) · 0원 0 남음 - ⚠ 같은 날짜에 0 과 제값이 함께 실림(2025-04-24) — 날짜만 보면 0 을 집음. **0 을 건너뛴 뒤** 가장 나중 공시를 고름 - 조용히 안 바꿈 — 줄마다 `price_adopted_reason` 「○ 공시에 값이 없어 ○ 값을 씀」 · 공시 **날짜·번호도 채택한 공시의 것**으로(어느 판 값인지 화면에서 보임) · `selection_policy` 에 0 건너뛰기 명시 · 못 고칠 줄 자리 `zero_price_unrecovered`(오늘은 빔) - 이름표에 새 열 「값을 고른 사유」 · 마스터가 바뀌어 폴더 지문(`_manifest`) sha·크기 갱신 - 새 시험 7건 — 0원이 남는지 · 사유가 붙는지 · 날짜·번호가 채택 공시와 같은지 · 같은 날 0 과 제값이 섞여도 제값을 집는지 · 0원 아닌 줄을 안 건드렸는지 · 앞 제값이 없으면 빨강이 아니라 목록에 남는지 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_cost_input_value/_build_mat_price.py | 100 +++ .../data_cost_input_value/_manifest.json | 4 +- .../mat_price_public_2026-08-14.json | 638 ++++++++++-------- .../data_master_labels/labels_2026-01-01.json | 41 +- resources/tester/test_mat_price_zero.py | 123 ++++ 5 files changed, 616 insertions(+), 290 deletions(-) create mode 100644 resources/data_cost_input_value/_build_mat_price.py create mode 100644 resources/tester/test_mat_price_zero.py diff --git a/resources/data_cost_input_value/_build_mat_price.py b/resources/data_cost_input_value/_build_mat_price.py new file mode 100644 index 00000000..858486e2 --- /dev/null +++ b/resources/data_cost_input_value/_build_mat_price.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값 공시를 채택**하는 규칙(2026-09-16 브레인). + +왜 — 우리 고르기 규칙이 「물품코드마다 가장 나중 공시」라, 값이 안 실린 공시(`prce = 0`)가 +옛 제값을 덮었다. 각형강관 25*25*1 이 `1,460 → 1,500 → 0` 으로 끝난 것이 그것이다. +「가격이 0」이 아니라 **그 공시에 값이 안 실린 것**이라, 0 을 값으로 받으면 안 된다. + +무엇을 고치나 + - 마스터의 0 원 줄만 고친다. **원천 스냅숏은 안 건드린다**(읽기만). + - 0 이 아닌 줄은 한 글자도 안 건드린다 — 규칙을 통째로 다시 돌리지 않는다. + - 고친 줄마다 **사유**(`price_adopted_reason`)를 남긴다. 조용히 바꾸지 않는다. + - 공시 날짜·번호도 **채택한 공시의 것**으로 바꾼다 — 어느 판 값인지 화면에서 보여야 한다. + - 앞 공시에도 값이 없어 0 이 남는 줄은 `zero_price_unrecovered` 로 낸다(규칙으로 못 고침). + +⚠ 같은 날짜에 0 과 제값이 함께 실린다(2025-04-24) — 날짜만 보지 말고 **0 을 건너뛴 뒤** + 가장 나중 공시를 고른다. + +돌리기: `./venv/Scripts/python.exe resources/data_cost_input_value/_build_mat_price.py` +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json" +MASTER = ROOT / "resources/data_cost_input_value/mat_price_public_2026-08-14.json" + +#: 값이 안 실린 공시를 가리는 잣대 — 빈 칸·0 은 「값 없음」이다. +EMPTY_PRICES = {"", "0", "0.0", "0.00"} + +SELECTION_POLICY = ( + "latest notice per product identification number within the local snapshot, " + "skipping notices whose price is empty or zero " + "(a zero price means the notice carried no price, not a price of zero)" +) +UNRECOVERED_NOTE = ( + "앞 공시에도 값이 없어 0 이 남은 줄. **규칙으로 못 고친다** — 0 을 값으로 쓰지 말고 " + "그 자재가 실제로 필요해지면 원천을 다시 받거나 견적을 받아야 한다." +) + + +def price_of(row: dict) -> str: + return str(row.get("prce", "")).strip() + + +def has_price(row: dict) -> bool: + return price_of(row) not in EMPTY_PRICES + + +def notice_day(row: dict) -> str: + return str(row.get("nticeDt", ""))[:10] + + +def build() -> dict: + raw = json.loads(RAW.read_text(encoding="utf-8")) + master = json.loads(MASTER.read_text(encoding="utf-8")) + records = master["variables"]["mat_price"]["records"] + + history: dict[str, list[dict]] = {} + for row in raw: + history.setdefault(str(row.get("prdctIdntNo")), []).append(row) + for rows in history.values(): + rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True) + + repaired, unrecovered = 0, [] + for record in records: + if str(record.get("price_krw")).strip() not in EMPTY_PRICES: + continue + was = notice_day(record | {"nticeDt": record.get("notice_datetime")}) + adopted = next((r for r in history.get(str(record["item_code"]), ()) if has_price(r)), None) + if adopted is None: + unrecovered.append( + { + "item_code": record["item_code"], + "classification_name": record.get("classification_name", ""), + "specification": record.get("specification", ""), + "notice_datetime": record.get("notice_datetime", ""), + "reason": "이 물품코드의 모든 공시에 값이 없음", + } + ) + continue + record["price_krw"] = int(price_of(adopted)) + record["notice_datetime"] = str(adopted.get("nticeDt", "")) + record["notice_number"] = str(adopted.get("prceNticeNo", "")) + record["price_adopted_reason"] = f"{was} 공시에 값이 없어 {notice_day(adopted)} 값을 씀" + repaired += 1 + + master["selection_policy"] = SELECTION_POLICY + master["zero_price_unrecovered"] = {"note": UNRECOVERED_NOTE, "items": unrecovered} + return {"master": master, "repaired": repaired, "unrecovered": len(unrecovered)} + + +if __name__ == "__main__": + result = build() + MASTER.write_text( + json.dumps(result["master"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}") diff --git a/resources/data_cost_input_value/_manifest.json b/resources/data_cost_input_value/_manifest.json index 71b93fe7..56945ecf 100644 --- a/resources/data_cost_input_value/_manifest.json +++ b/resources/data_cost_input_value/_manifest.json @@ -48,8 +48,8 @@ "file": "mat_price_public_2026-08-14.json", "dataset_id": "mat_price_public", "effective_date": "2026-08-14", - "sha256": "4f5f76cf5b39ee4e3152fec11b9f5efc70ecea075a66a5cc1bda6ff7691de5b7", - "size_bytes": 4741651 + "sha256": "75ddfe85b0140d782cd00187ec459e995cd0db9982e7bd48f62f83cd65248acc", + "size_bytes": 4748976 }, { "file": "oil_2026-08-14.json", diff --git a/resources/data_cost_input_value/mat_price_public_2026-08-14.json b/resources/data_cost_input_value/mat_price_public_2026-08-14.json index 42ef3562..cb63bf93 100644 --- a/resources/data_cost_input_value/mat_price_public_2026-08-14.json +++ b/resources/data_cost_input_value/mat_price_public_2026-08-14.json @@ -10,7 +10,7 @@ "role": "primary" } ], - "selection_policy": "latest notice per product identification number within the local snapshot", + "selection_policy": "latest notice per product identification number within the local snapshot, skipping notices whose price is empty or zero (a zero price means the notice carried no price, not a price of zero)", "source_row_count": 27485, "variables": { "mat_price": { @@ -25702,15 +25702,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 25*25*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306448", + "price_krw": 1500, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907910", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110392", @@ -25734,15 +25735,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 30*30*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306451", + "price_krw": 1770, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907913", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110397", @@ -25766,15 +25768,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 40*40*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306455", + "price_krw": 2360, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907917", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110405", @@ -25782,15 +25785,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 50*50*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306460", + "price_krw": 2940, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907922", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110406", @@ -26230,15 +26234,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 30*20*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306495", + "price_krw": 1500, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907957", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110471", @@ -26278,15 +26283,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 40*20*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306498", + "price_krw": 1770, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907960", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110476", @@ -26326,15 +26332,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 50*30*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306502", + "price_krw": 2360, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907964", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110486", @@ -26470,15 +26477,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 100*50*1.4t", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306518", + "price_krw": 4560, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907980", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20110518", @@ -47670,15 +47678,16 @@ "classification_name": "컨테이너하우스", "specification": "컨테이너하우스, 사무실용, 2.4*9.0*2.6m", "unit": "개", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306873", + "price_krw": 5375598, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908335", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139632", @@ -47686,15 +47695,16 @@ "classification_name": "컨테이너하우스", "specification": "컨테이너하우스, 사무실용, 2.4*12*2.6m", "unit": "개", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306874", + "price_krw": 7245371, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908336", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139633", @@ -47862,15 +47872,16 @@ "classification_name": "컨테이너하우스", "specification": "컨테이너하우스, 창고용, 2.4*9.0*2.6m", "unit": "개", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306888", + "price_krw": 4125994, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908350", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139655", @@ -47990,15 +48001,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, BASE CHANNEL, 2.0mm이상", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306774", + "price_krw": 3724, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908236", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139692", @@ -48006,15 +48018,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, TOP CHANEL, 2.0mm이상", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306775", + "price_krw": 3244, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908237", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139693", @@ -48022,15 +48035,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 외부판넬, 벽, 1.2*2.4m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306776", + "price_krw": 41213, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908238", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139694", @@ -48038,15 +48052,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 외부판넬, 창문, 1.2*2.4m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306777", + "price_krw": 74699, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908239", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139695", @@ -48054,15 +48069,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 외부판넬, 철재문, 1.2*2.4m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306778", + "price_krw": 161892, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908240", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139696", @@ -48070,15 +48086,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 내부판넬, 벽, 1.2*2.4m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306779", + "price_krw": 36816, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908241", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139698", @@ -48086,15 +48103,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 판넬죠인트, 2.4m", "unit": "조", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306780", + "price_krw": 6636, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908242", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139699", @@ -48102,15 +48120,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, CANOPY, 0.6*1.2m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306781", + "price_krw": 23795, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908243", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139700", @@ -48118,15 +48137,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 박공판넬, 0.6*1.2m", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306782", + "price_krw": 9361, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908244", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139701", @@ -48134,15 +48154,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 루우프시이트, 칼라시트, 0.5mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306783", + "price_krw": 7568, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908245", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139702", @@ -48150,15 +48171,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 트러스, 7.2m", "unit": "개", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306784", + "price_krw": 60504, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908246", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139703", @@ -48166,15 +48188,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 중도리, c-60*30*10*2.3mm", "unit": "개", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306785", + "price_krw": 1608, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908247", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139704", @@ -48182,15 +48205,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, 천정판+그라스울 50mm", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306786", + "price_krw": 11714, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908248", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139705", @@ -48198,15 +48222,16 @@ "classification_name": "조립식건물부재", "specification": "조립식건물부재, T-BAR, 24*38*0.4mm", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306787", + "price_krw": 1343, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908249", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20139910", @@ -48326,15 +48351,16 @@ "classification_name": "건설용거푸집", "specification": "건설용거푸집, 강, 600*1500*63.5mm", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304954", + "price_krw": 49912, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906392", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20140323", @@ -48358,15 +48384,16 @@ "classification_name": "건설용거푸집", "specification": "건설용거푸집, 강, 450*1500*63.5mm", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304956", + "price_krw": 29704, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906394", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20140335", @@ -48390,15 +48417,16 @@ "classification_name": "건설용거푸집", "specification": "건설용거푸집, 강, 400*1500*63.5mm", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304958", + "price_krw": 26893, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906396", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20140339", @@ -48422,15 +48450,16 @@ "classification_name": "건설용거푸집", "specification": "건설용거푸집, 강, 300*1500*63.5mm", "unit": "매", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304960", + "price_krw": 23599, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906398", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20140347", @@ -50582,15 +50611,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 200mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305909", + "price_krw": 114118, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907371", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20153195", @@ -51110,15 +51140,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 450*450*t3mm, VIP타일, 마블", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304864", + "price_krw": 34382, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906302", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20153979", @@ -51894,15 +51925,16 @@ "classification_name": "불연천장재", "specification": "불연천장재, 아스텍스, 6*300*600mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305660", + "price_krw": 4908, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907122", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20155076", @@ -60198,15 +60230,16 @@ "classification_name": "광택제", "specification": "광택제, 수산", "unit": "kg", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305219", + "price_krw": 3211, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906681", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20277439", @@ -60342,15 +60375,16 @@ "classification_name": "합성풀", "specification": "합성풀, 건설용", "unit": "kg", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306963", + "price_krw": 9376, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908425", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20277692", @@ -62902,15 +62936,16 @@ "classification_name": "평강", "specification": "평강, t3*13∼16mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306930", + "price_krw": 930000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908392", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20289313", @@ -62918,15 +62953,16 @@ "classification_name": "평강", "specification": "평강, t4*13∼16mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306931", + "price_krw": 930000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908393", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20289314", @@ -62934,15 +62970,16 @@ "classification_name": "평강", "specification": "평강, t4.5*13∼16mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306932", + "price_krw": 930000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908394", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20289315", @@ -62950,15 +62987,16 @@ "classification_name": "평강", "specification": "평강, t5*13∼16mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306933", + "price_krw": 930000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908395", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20289316", @@ -64646,15 +64684,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 910*100*t3mm, 골드우드타일", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304870", + "price_krw": 15842, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906308", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20434126", @@ -64662,15 +64701,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 450*450*t3mm, 골드타일", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304869", + "price_krw": 14444, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906307", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20434127", @@ -64694,15 +64734,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 450*450*t3mm, MVP타일", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304867", + "price_krw": 9505, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906305", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20434133", @@ -64838,15 +64879,16 @@ "classification_name": "불연천장재", "specification": "불연천장재, 아스텍스, 6*300*600mm, 유공", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305661", + "price_krw": 7981, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907123", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20434404", @@ -64854,15 +64896,16 @@ "classification_name": "불연천장재", "specification": "불연천장재, SF-1200, M-Bar용, 12*300*600mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305679", + "price_krw": 8240, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907141", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20434423", @@ -66374,15 +66417,16 @@ "classification_name": "불연천장재", "specification": "불연천장재, EZ-1200, 이지톤, M-bar용, 12*300*600", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305690", + "price_krw": 8120, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907152", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20612102", @@ -66390,15 +66434,16 @@ "classification_name": "화학섬유카펫", "specification": "화학섬유카펫, 코오롱, CA, 9mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306965", + "price_krw": 35246, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908427", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20683679", @@ -74422,15 +74467,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 450*450*t3mm, VIP타일, 포스트", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304865", + "price_krw": 34382, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906303", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20730857", @@ -74742,15 +74788,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 60*40*1.4mm", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306507", + "price_krw": 2940, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907969", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20814168", @@ -74790,15 +74837,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 75*45*1.4mm", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306512", + "price_krw": 3540, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907974", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20814171", @@ -76566,15 +76614,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 120mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305906", + "price_krw": 91900, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907368", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20969828", @@ -76582,15 +76631,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 150mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305907", + "price_krw": 101999, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907369", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20969829", @@ -76598,15 +76648,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 175mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305908", + "price_krw": 106039, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907370", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "20969844", @@ -87430,15 +87481,16 @@ "classification_name": "일반구조용압연강판", "specification": "일반구조용압연강판, STS평철304, 2.0mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306541", + "price_krw": 5100000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908003", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21868520", @@ -87510,15 +87562,16 @@ "classification_name": "일반구조용압연강판", "specification": "일반구조용압연강판, STS평철304, 7.0mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306546", + "price_krw": 6064000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908008", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21868525", @@ -87574,15 +87627,16 @@ "classification_name": "일반구조용압연강판", "specification": "일반구조용압연강판, STS평철304, 16mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306550", + "price_krw": 6064000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908012", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21868529", @@ -87590,15 +87644,16 @@ "classification_name": "일반구조용압연강판", "specification": "일반구조용압연강판, STS평철304, 19mm", "unit": "톤", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306551", + "price_krw": 6400000, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908013", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21868530", @@ -89398,15 +89453,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 60*60*t1.4mm", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306466", + "price_krw": 3540, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907928", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21871653", @@ -89446,15 +89502,16 @@ "classification_name": "일반구조용각형강관", "specification": "일반구조용각형강관, 각형강관, 75*75*t1.4mm", "unit": "m", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306472", + "price_krw": 4560, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907934", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "21871816", @@ -89878,15 +89935,16 @@ "classification_name": "화학섬유카펫", "specification": "화학섬유카펫, 스완, RQ, 9mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306964", + "price_krw": 35246, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908426", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22073563", @@ -89894,15 +89952,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 265mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305910", + "price_krw": 120178, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907372", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22073564", @@ -89910,15 +89969,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 스판크리트, 315mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305911", + "price_krw": 130277, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907373", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22073570", @@ -91574,15 +91634,16 @@ "classification_name": "장판지", "specification": "장판지, 명가, 2.2T*1830", "unit": "M2", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24306744", + "price_krw": 13698, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22908206", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22256401", @@ -91766,15 +91827,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이, 일면반강화, 24mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305654", + "price_krw": 68072, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466476", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "22365230", @@ -91782,15 +91844,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이, 양면반강화, 24mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305657", + "price_krw": 83142, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466482", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "22365241", @@ -93494,15 +93557,16 @@ "classification_name": "PVC바닥재", "specification": "PVC바닥재, 비닐바닥재, 소리지움, 4.5t*1830", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24304876", + "price_krw": 26838, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22906314", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22446972", @@ -99702,15 +99766,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 벽판용속빈PC판, 스판크리트, 120mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305912", + "price_krw": 90891, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907374", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22616476", @@ -99718,15 +99783,16 @@ "classification_name": "슬래브용속빈PC판", "specification": "슬래브용속빈PC판, 벽판용속빈PC판, 스판크리트, 150mm(45)", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305913", + "price_krw": 100990, + "notice_datetime": "2025-04-24 00:00:00", + "notice_number": "R25CW22907375", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2025-04-24 값을 씀" }, { "item_code": "22617062", @@ -101686,15 +101752,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이, 투명일면반강화, 28mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305656", + "price_krw": 83415, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466480", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "23290408", @@ -101702,15 +101769,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이, 투명양면반강화, 28mm", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305659", + "price_krw": 95974, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466486", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "23375752", @@ -101926,15 +101994,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이복층/투명 일면반강화, 26mm 5+16+5", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305655", + "price_krw": 73980, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466478", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "23574727", @@ -101942,15 +102011,16 @@ "classification_name": "복층유리", "specification": "복층유리, 로이복층/투명 양면반강화, 26mm 5+16+5", "unit": "㎡", - "price_krw": 0, - "notice_datetime": "2025-10-23 00:00:00", - "notice_number": "R25CW24305658", + "price_krw": 91251, + "notice_datetime": "2024-10-30 16:58:14", + "notice_number": "R24CW21466484", "business_division_code": "포110002", "business_division_name": "시설공통자재(건축)", "vat_basis": "부가가치세별도", "price_type": "기타가격", "delivery_condition": "현장도착도", - "field": "건축" + "field": "건축", + "price_adopted_reason": "2025-10-23 공시에 값이 없어 2024-10-30 값을 씀" }, { "item_code": "23576116", @@ -112016,5 +112086,9 @@ "group": "asphalt_concrete", "reason": "shopping mall snapshot not locally stored" } - ] + ], + "zero_price_unrecovered": { + "note": "앞 공시에도 값이 없어 0 이 남은 줄. **규칙으로 못 고친다** — 0 을 값으로 쓰지 말고 그 자재가 실제로 필요해지면 원천을 다시 받거나 견적을 받아야 한다.", + "items": [] + } } diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 6577a6ae..4607da7e 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-16T10:49:25+09:00", + "generated_at": "2026-09-16T11:39:55+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -210,7 +210,15 @@ "key": "notice_datetime", "name_ko": "공고 일시", "unit": "", - "visible": true + "visible": true, + "note": "채택한 공시의 날짜 — 0 원 공시를 건너뛴 줄은 앞 공시 날짜가 선다." + }, + { + "key": "price_adopted_reason", + "name_ko": "값을 고른 사유", + "unit": "", + "visible": true, + "note": "⭐ 0 원 공시를 건너뛰고 앞 제값을 쓴 줄에만 선다(70 줄). 「○ 공시에 값이 없어 ○ 값을 씀」." }, { "key": "notice_number", @@ -1079,14 +1087,14 @@ }, "counts": { "merged_tables": 5, - "merged_columns": 88, + "merged_columns": 89, "files": 34, "tables": 91, - "columns": 455, - "value_groups": 172, + "columns": 456, + "value_groups": 173, "unknown": 0, "value_groups_by_kind": { - "doc": 106, + "doc": 107, "value": 66 } }, @@ -4382,6 +4390,12 @@ "name_ko": "분야", "unit": "", "visible": true + }, + { + "key": "price_adopted_reason", + "name_ko": "값을 고른 사유", + "unit": "", + "visible": true } ] } @@ -4404,6 +4418,12 @@ "name_ko": "줄을 가르는 칸", "summary": "이 표에서 줄 하나를 집는 칸 이름.", "kind": "doc" + }, + { + "key": "zero_price_unrecovered", + "name_ko": "값을 못 찾은 0 원 줄", + "summary": "앞 공시에도 값이 없어 0 이 남은 줄. 규칙으로 못 고친다 — 그 자재가 필요해지면 원천을 다시 받아야 한다.", + "kind": "doc" } ] }, @@ -6765,6 +6785,11 @@ "unit": "", "visible": true }, + "price_adopted_reason": { + "name_ko": "값을 고른 사유", + "unit": "", + "visible": true + }, "price_krw": { "name_ko": "단가", "unit": "원", @@ -7566,6 +7591,10 @@ "name_ko": "원천 줄 수", "summary": "걸러 내기 전 원본 줄 수." }, + "mat_price_public::zero_price_unrecovered": { + "name_ko": "값을 못 찾은 0 원 줄", + "summary": "앞 공시에도 값이 없어 0 이 남은 줄. 규칙으로 못 고친다 — 그 자재가 필요해지면 원천을 다시 받아야 한다." + }, "material_surcharge::observed_practice": { "name_ko": "실무 관측", "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다." diff --git a/resources/tester/test_mat_price_zero.py b/resources/tester/test_mat_price_zero.py new file mode 100644 index 00000000..5c2bfa42 --- /dev/null +++ b/resources/tester/test_mat_price_zero.py @@ -0,0 +1,123 @@ +"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값을 쓴다**(2026-09-16 브레인). + +왜 — 우리 고르기 규칙이 「물품코드마다 가장 나중 공시」라, 값이 안 실린 공시(`prce = 0`)가 +옛 제값을 덮었다(각형강관 25*25*1.4t `1,460 → 1,500 → 0`). 「가격이 0」이 아니라 +**그 공시에 값이 안 실린 것**이라, 0 을 값으로 받으면 안 된다. + +⚠ 조용히 바꾸지 않는다 — 고친 줄에는 사유가 남고, 공시 날짜·번호도 **채택한 공시의 것**이 된다. +⚠ 앞 공시에도 값이 없어 0 이 남는 줄은 **빨강이 아니라 목록**(`zero_price_unrecovered`)으로 남는다. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MASTER = ROOT / "resources" / "data_cost_input_value" / "mat_price_public_2026-08-14.json" +RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json" +EMPTY_PRICES = {"", "0", "0.0", "0.00"} + + +@pytest.fixture(scope="module") +def master() -> dict: + return json.loads(MASTER.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def records(master) -> list[dict]: + return master["variables"]["mat_price"]["records"] + + +def _empty(value) -> bool: + return str(value).strip() in EMPTY_PRICES + + +def test_값이_없는_줄은_목록에만_남는다(master, records): + """⭐ 규칙으로 고칠 수 있는 0 원 줄은 하나도 안 남는다. + + 앞 공시에도 값이 없는 줄만 `zero_price_unrecovered` 에 남고, 그 줄은 표에서도 0 이다. + """ + listed = {str(item["item_code"]) for item in master["zero_price_unrecovered"]["items"]} + zeros = {str(r["item_code"]) for r in records if _empty(r.get("price_krw"))} + assert zeros == listed, f"목록에 없는 0 원 줄: {sorted(zeros - listed)}" + + +def test_못_고친_줄에는_사유가_붙는다(master): + for item in master["zero_price_unrecovered"]["items"]: + assert item["reason"].strip(), item["item_code"] + assert master["zero_price_unrecovered"]["note"].strip() + + +def test_고친_줄마다_사유가_남는다(records): + """조용히 바꾸지 않는다 — 「○ 공시에 값이 없어 ○ 값을 씀」이 줄에 남아야 한다.""" + repaired = [r for r in records if r.get("price_adopted_reason")] + assert repaired, "고친 줄이 하나도 없다" + for row in repaired: + reason = row["price_adopted_reason"] + assert "값이 없어" in reason and "씀" in reason, reason + assert not _empty(row["price_krw"]), row["item_code"] + + +def test_고친_줄의_공시_날짜와_번호가_채택한_공시의_것이다(records): + """어느 판 값인지 화면에서 보여야 한다 — 날짜가 0 원 공시에 머물면 안 된다.""" + raw = json.loads(RAW.read_text(encoding="utf-8")) + history: dict[str, list[dict]] = {} + for row in raw: + history.setdefault(str(row.get("prdctIdntNo")), []).append(row) + for rows in history.values(): + rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True) + + wrong = [] + for row in (r for r in records if r.get("price_adopted_reason")): + adopted = next( + (h for h in history.get(str(row["item_code"]), ()) if not _empty(h.get("prce"))), + None, + ) + assert adopted is not None, row["item_code"] + if str(row["price_krw"]) != str(int(str(adopted["prce"]).strip())): + wrong.append(f"{row['item_code']} 단가 {row['price_krw']} ≠ {adopted['prce']}") + elif row["notice_datetime"] != str(adopted.get("nticeDt", "")): + wrong.append(f"{row['item_code']} 날짜 {row['notice_datetime']}") + elif row["notice_number"] != str(adopted.get("prceNticeNo", "")): + wrong.append(f"{row['item_code']} 번호 {row['notice_number']}") + elif ( + row["price_adopted_reason"].endswith(f"{str(adopted['nticeDt'])[:10]} 값을 씀") is False + ): + wrong.append(f"{row['item_code']} 사유가 채택 공시와 다름") + assert not wrong, wrong + + +def test_0원_아닌_줄은_안_건드린다(records): + """규칙을 통째로 다시 돌리지 않는다 — 사유가 붙은 줄만 손댄 줄이다.""" + touched = [r for r in records if r.get("price_adopted_reason")] + assert len(touched) < len(records) // 10, ( + "손댄 줄이 너무 많다 — 규칙이 전체를 다시 쓴 것 아닌가" + ) + assert len(records) == 6999, "줄 수가 바뀌었다" + + +def test_고르는_방침에_0원_건너뛰기가_적혀_있다(master): + policy = master["selection_policy"] + assert "zero" in policy and "skip" in policy.lower(), policy + + +def test_같은_날짜에_0과_제값이_함께_있어도_제값을_고른다(): + """2025-04-24 는 같은 날 0 과 제값이 함께 실린다 — 날짜만 보면 0 을 집을 수 있다.""" + raw = json.loads(RAW.read_text(encoding="utf-8")) + same_day: dict[str, set[bool]] = {} + for row in raw: + if str(row.get("nticeDt", ""))[:10] != "2025-04-24": + continue + same_day.setdefault(str(row.get("prdctIdntNo")), set()).add(_empty(row.get("prce"))) + both = [code for code, kinds in same_day.items() if kinds == {True, False}] + assert both, "같은 날 0 과 제값이 함께 있는 물품이 없다 — 시험 전제가 바뀌었다" + + master = json.loads(MASTER.read_text(encoding="utf-8")) + rows = {str(r["item_code"]): r for r in master["variables"]["mat_price"]["records"]} + for code in both: + row = rows.get(code) + if row is not None: + assert not _empty(row["price_krw"]), f"{code} 가 0 으로 섰다" From 4a6de21552ca8c45aaf8efae8077cc9949e94d8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 13:08:09 +0900 Subject: [PATCH 33/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=95=84=ED=99=89=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=8B=A8=EA=B0=80=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B8=B8=EB=A1=9C=20=E2=80=94=20kind=20=EC=97=B4=EB=84=B7(coef?= =?UTF-8?q?=20=ED=86=A0=EB=9F=89=ED=99=98=EC=82=B0=20=C2=B7=20material=5Fs?= =?UTF-8?q?urcharge=20=C2=B7=20formwork=5Freuse=20=C2=B7=20rebar=5Fcomplex?= =?UTF-8?q?ity=20=C2=B7=20timber=5Fstructure=5Fclass=20=C2=B7=20masonry=5F?= =?UTF-8?q?slope=20=C2=B7=20masonry=5Fback=5Flength=20=C2=B7=20stone=5Fkin?= =?UTF-8?q?d=20=C2=B7=20masonry=5Fclass)=20=C2=B7=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=EB=8A=94=20=EC=B6=95=EC=9D=84=20=EC=8A=AC=EB=9E=98=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C(=EA=B5=AC=EA=B0=84=20=EC=9D=B4=EB=A6=84=EC=9D=80=20?= =?UTF-8?q?=ED=92=88=EC=85=88=20=EC=9B=90=EB=AC=B8=20=ED=91=9C=EA=B8=B0)?= =?UTF-8?q?=20=C2=B7=20=EC=B6=95=20=EC=B9=B8=EC=9D=80=20=EB=AA=BB=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8(=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EA=B0=92=C2=B7?= =?UTF-8?q?=EC=B6=95=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EB=8F=84=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C)=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=91=90=20=EC=A4=84=EC=9D=80=20=EA=B0=92=EC=9D=B4?= =?UTF-8?q?=20=EA=B0=99=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20+=20=EC=82=AC=EC=9C=A0(=EA=B0=88=EB=A6=AC=EB=A9=B4?= =?UTF-8?q?=20=EB=A9=88=EC=B6=A4)=20=C2=B7=20=EC=9D=B4=EC=9D=8C=ED=91=9C?= =?UTF-8?q?=C2=B7=ED=92=88=EC=85=88=20=EC=9B=90=EB=AC=B8=20=ED=91=9C=C2=B7?= =?UTF-8?q?=EA=B8=80=C2=B7=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=C2=B7=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EA=B5=90=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9D=80=20=EC=95=88=20=ED=83=9C=EC=9B=80(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9E=A3=EB=8C=80=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EC=8A=B9=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 55 ++- .../Z01_MasterData_BasePrices_Tables.py | 323 ++++++++++++++++++ .../tester/test_master_labels_base_prices.py | 3 +- resources/tester/test_z01_base_tables.py | 176 ++++++++++ 4 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices_Tables.py create mode 100644 resources/tester/test_z01_base_tables.py diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 615730c1..1157d334 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -17,10 +17,12 @@ from pathlib import Path from typing import Any from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine +from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Tables as tables -KINDS = ("labor", "machine", "material", "oil", "rate") +#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로 +KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC) SOURCES_DIR = ( tables.RESOURCES / "data_master_sources" ) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지 @@ -234,11 +236,46 @@ def _rate() -> list[dict[str, Any]]: _BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate} +class DuplicateRowError(ValueError): + """열쇠가 같은 줄인데 값이 다름 — 합치면 틀린 값이 섬(브레인 조건 · 품셈 개정으로 갈리는 날).""" + + +def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """열쇠가 같고 **값도 같은** 줄은 한 줄로 — 원문 두 줄(coef 「암괴…점토」)이 그 자리. + + 값이 갈리면 멈춤: 원문이 개정돼 두 줄이 달라지는 날 조용히 한 줄로 합치면 안 됨. + """ + body = lambda r: { # noqa: E731 — 견줄 알맹이(붙인 사유·줄 수는 뺌) + k: v + for k, v in r.items() + if not k.startswith("@") and k not in ("source_rows", "duplicate_note") + } + first: dict[str, dict[str, Any]] = {} + out = [] + for row in rows: + kept = first.get(row["@id"]) + if kept is None: + first[row["@id"]] = row + out.append(row) + continue + if body(kept) != body(row): + raise DuplicateRowError(f"열쇠가 같은데 값이 다름: {row['@id']}") + kept["source_rows"] = kept.get("source_rows", 1) + 1 + kept["duplicate_note"] = ( + "원문 두 줄 · 값 같음 — 한 줄로 보임(원문 오기 가능성 · 임의 보정 없음)" + ) + return out + + def base_rows(kind: str) -> list[dict[str, Any]]: """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" if kind == "machine": doc, name = _doc("mach_base") return machine.base_rows(name, edition_of(doc)) + spec_of = base_tables.SPEC.get(kind) + if spec_of: + doc, name = _doc(spec_of["file"]) + return merge_same_rows(spec_of["build"](doc, name)) return _BUILDERS[kind]() @@ -310,6 +347,14 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]: """표 수준 — editable · locked · formula.""" if kind == "machine": return machine.spec(columns) + if kind in base_tables.SPEC: + editable = [c for c in base_tables.SPEC[kind]["editable"] if c in columns] + axis = {a for r in base_rows(kind) for a in r.get("@axis", ())} + return { + "editable": editable, + "locked": {c: _KEY if c in axis else _SOURCE for c in columns if c not in editable}, + "formula": {}, + } editable = { "labor": ["daily_wage_krw"], "material": ["price_krw"], @@ -343,6 +388,8 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str: return str(row.get("specification") or row.get("classification_name") or "") if kind == "oil": return f"{row.get('source_product_name') or row.get('fuel')} {row.get('region_name') or '전국평균'}" + if kind in base_tables.SPEC: # 기초데이터 아홉 — 축 칸을 이어 사람이 읽을 한 줄로 + return " · ".join(str(row[c]) for c in row.get("@axis", ()) if row.get(c) is not None) named = (tables.load_labels().get("value_overrides") or {}).get( f"rates::variables/{row['variable']}" ) or {} @@ -385,8 +432,8 @@ def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, def public(row: dict[str, Any]) -> dict[str, Any]: - """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" - return {k: v for k, v in row.items() if k != "@source"} + """내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌.""" + return {k: v for k, v in row.items() if k not in ("@source", "@axis")} def table( @@ -434,6 +481,8 @@ def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, return 404, {"message": f"없는 줄: {row_id}"} info = spec(kind, _columns(kind, rows(kind))) for column, value in values.items(): + if column in (base.get("@axis") or ()): + return 400, {"message": f"못 고치는 칸 {column} — {_KEY}"} if column not in info["editable"] or column not in base: reason = info["locked"].get(column) or "이 줄에 없는 칸" return 400, {"message": f"못 고치는 칸 {column} — {reason}"} diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py new file mode 100644 index 00000000..da07b770 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -0,0 +1,323 @@ +"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1(2026-09-16 사용자 지시 · 브레인 승인). + +사용자 잣대: 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.** + 태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래 + 안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 · + 구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임) +⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음. +⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건). +""" + +from __future__ import annotations + +from typing import Any + +_ROW = dict[str, Any] + + +def _brackets(steps: list[float]) -> list[str]: + """직고 구간 이름 — 원문 표기(`∼1.5 · ∼3 · ∼5 · ∼7 · 7이상`)를 그대로 씀.""" + return [f"~{s:g}" for s in steps] + [f"{steps[-1]:g}이상"] + + +def _row(row_id: str, source: str, axis: tuple[str, ...], **cells: Any) -> _ROW: + return {"@id": row_id, "@source": source, "@axis": axis, **cells} + + +def _coef(doc: dict[str, Any], name: str) -> list[_ROW]: + """토량환산계수 L·C — 품셈 체적변화율. 원문 표(`surcharge_*`)·공구손료율은 여기 아님.""" + rows = [] + for table in ("coef_soil_L", "coef_soil_C"): + for record in doc["variables"][table]["records"]: + rows.append( + _row( + f"{table}/{record['soil_type']}", + name, + ("table", "soil_type"), + table=table, + soil_type=record["soil_type"], + min=record.get("min"), + max=record.get("max"), + rule=record.get("rule"), + selection=record.get("selection"), + ) + ) + return rows + + +def _material_surcharge(doc: dict[str, Any], name: str) -> list[_ROW]: + return [ + _row( + f"rates_pct/{r['material']}", + name, + ("material",), + material=r["material"], + rate=r.get("rate"), + condition=r.get("condition"), + alt_rate=r.get("alt_rate"), + alt_condition=r.get("alt_condition"), + pumsem=r.get("pumsem"), + ) + for r in doc["rates_pct"] + ] + + +def _formwork_reuse(doc: dict[str, Any], name: str) -> list[_ROW]: + rows = [] + for r in doc["reuse_by_class"]: + rows.append( + _row( + f"reuse_by_class/{r['class']}", + name, + ("table", "class"), + table="reuse_by_class", + **{"class": r["class"]}, + reuse_count=r.get("reuse_count"), + examples=r.get("examples"), + ) + ) + ratios = doc["reuse_ratio_pct"] + for material in ("plywood", "timber"): + for count, percent in (ratios.get(material) or {}).items(): + rows.append( + _row( + f"reuse_ratio_pct/{material}/{count}", + name, + ("table", "material", "reuse_count"), + table="reuse_ratio_pct", + material=material, + reuse_count=int(count), + ratio_pct=percent, + ) + ) + for r in doc["euroform_type"]["classes"]: + rows.append( + _row( + f"euroform_type/classes/{r['key']}", + name, + ("table", "class"), + table="euroform_type/classes", + **{"class": r["key"]}, + daily_area_m2=r.get("daily_area_m2"), + examples=r.get("examples"), + ) + ) + return rows + + +def _rebar_complexity(doc: dict[str, Any], name: str) -> list[_ROW]: + rows = [ + _row( + f"classes/{r['key']}", + name, + ("table", "class"), + table="classes", + **{"class": r["key"]}, + examples=r.get("examples"), + ) + for r in doc["classes"] + ] + for key, price in (doc["price_hint_krw_per_ton"].get("values") or {}).items(): + rows.append( + _row( + f"price_hint_krw_per_ton/{key}", + name, + ("table", "class"), + table="price_hint_krw_per_ton", + **{"class": key}, + price_krw_per_ton=price, + ) + ) + return rows + + +def _timber_structure_class(doc: dict[str, Any], name: str) -> list[_ROW]: + return [ + _row( + f"classes/{r['key']}", + name, + ("class",), + **{"class": r["key"]}, + carpenter=r.get("carpenter"), + laborer=r.get("laborer"), + examples=r.get("examples"), + ) + for r in doc["classes"] + ] + + +def _masonry_slope(doc: dict[str, Any], name: str) -> list[_ROW]: + brackets = _brackets(doc["steps_m"]) + rows = [] + for bond, faces in doc["table"].items(): + for face, values in faces.items(): + for bracket, ratio in zip(brackets, values): + rows.append( + _row( + f"table/{bond}/{face}/{bracket}", + name, + ("bond", "face", "height_bracket"), + bond=bond, + face=face, + height_bracket=bracket, + face_slope_ratio=ratio, + ) + ) + return rows + + +def _masonry_back_length(doc: dict[str, Any], name: str) -> list[_ROW]: + brackets = _brackets(doc["steps_m"]) + rows = [] + for bond, pairs in doc["table_cm"].items(): + for bracket, pair in zip(brackets, pairs): + rows.append( + _row( + f"table_cm/{bond}/{bracket}", + name, + ("bond", "height_bracket"), + bond=bond, + height_bracket=bracket, + min_cm=pair[0], + max_cm=pair[1], # 원문 「-」 는 그대로 빈 칸 + ) + ) + return rows + + +def _stone_kind(doc: dict[str, Any], name: str) -> list[_ROW]: + rows = [] + + def unit_rows(table: str, holder: dict[str, Any]) -> None: + for kind, lengths in holder.items(): + if not isinstance(lengths, dict): + continue + for cm, value in lengths.items(): + rows.append( + _row( + f"{table}/{kind}/{cm}", + name, + ("table", "stone_kind", "back_length_cm"), + table=table, + stone_kind=kind, + back_length_cm=int(cm), + m3_per_m2=value, + ) + ) + + for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + unit_rows(table, doc[table]) + for kind, ratio in doc["backfill_ratio_of_back_length"].items(): + if isinstance(ratio, (int, float)): + rows.append( + _row( + f"backfill_ratio_of_back_length/{kind}", + name, + ("table", "stone_kind"), + table="backfill_ratio_of_back_length", + stone_kind=kind, + ratio=ratio, + ) + ) + fallback = doc["fallback"] # 돌 종류 미지정일 때 쓰는 한 벌(건설품셈 참고자료) + for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + for cm, value in (fallback.get(table) or {}).items(): + rows.append( + _row( + f"fallback/{table}/{cm}", + name, + ("table", "back_length_cm"), + table=f"fallback/{table}", + back_length_cm=int(cm), + m3_per_m2=value, + ) + ) + if isinstance(fallback.get("backfill_ratio_of_back_length"), (int, float)): + rows.append( + _row( + "fallback/backfill_ratio_of_back_length", + name, + ("table",), + table="fallback/backfill_ratio_of_back_length", + ratio=fallback["backfill_ratio_of_back_length"], + ) + ) + return rows + + +def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]: + rows = [ + _row( + f"back_length/classes/{r['key']}", + name, + ("table", "class"), + table="back_length/classes", + **{"class": r["key"]}, + max_cm=r.get("max_cm"), + ) + for r in doc["back_length"]["classes"] + ] + for value in doc["boulder_diameter"]["classes"]: + rows.append( + _row( + f"boulder_diameter/{value}", + name, + ("table", "class"), + table="boulder_diameter", + **{"class": value}, + ) + ) + for type_id, ratio in doc["face_slope"]["by_type"].items(): + rows.append( + _row( + f"face_slope/{type_id}", + name, + ("table", "type_id"), + table="face_slope", + type_id=type_id, + face_slope_ratio=ratio, + ) + ) + return rows + + +#: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인). +SPEC: dict[str, dict[str, Any]] = { + "coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]}, + "material_surcharge": { + "file": "material_surcharge", + "build": _material_surcharge, + "editable": ["rate", "alt_rate"], + }, + "formwork_reuse": { + "file": "formwork_reuse", + "build": _formwork_reuse, + "editable": ["reuse_count", "ratio_pct", "daily_area_m2"], + }, + "rebar_complexity": { + "file": "rebar_complexity", + "build": _rebar_complexity, + "editable": ["price_krw_per_ton"], + }, + "timber_structure_class": { + "file": "timber_structure_class", + "build": _timber_structure_class, + "editable": ["carpenter", "laborer"], + }, + "masonry_slope": { + "file": "masonry_slope", + "build": _masonry_slope, + "editable": ["face_slope_ratio"], + }, + "masonry_back_length": { + "file": "masonry_back_length", + "build": _masonry_back_length, + "editable": ["min_cm", "max_cm"], + }, + "stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]}, + "masonry_class": { + "file": "masonry_class", + "build": _masonry_class, + "editable": ["max_cm", "face_slope_ratio"], + }, +} diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index 0e455f08..801bd9b0 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -37,7 +37,8 @@ def shown() -> dict[str, list[dict]]: def test_다섯_표가_다_선다(shown): - assert set(shown) == {"labor", "machine", "material", "oil", "rate"} + # 2026-09-16 기초데이터 아홉이 더해져 열넷(사용자 지시 · 브레인) — 다섯은 그 안에 그대로 + assert {"labor", "machine", "material", "oil", "rate"} <= set(shown) == set(base_prices.KINDS) for kind, columns in shown.items(): assert columns, f"{kind} 에 열이 하나도 없다" diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py new file mode 100644 index 00000000..429320b9 --- /dev/null +++ b/resources/tester/test_z01_base_tables.py @@ -0,0 +1,176 @@ +"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1 을 기초단가와 **같은 길**로(2026-09-16 사용자 지시 · 브레인). + +잣대(사용자): 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.** + 태움 — coef(토량환산) · material_surcharge · formwork_reuse · rebar_complexity · timber_structure_class · + masonry_slope · masonry_back_length · stone_kind · masonry_class + 안 태움 — 이음표(type_map·form_map·bond.codes) · 품셈 원문 표(coef surcharge_*) · 글·방침 · + structure_unit_observed(울진 한 현장 관찰값) · revetment_sabang(교본값 + 글 섞임) — 트리엔 남되 고치기 없음 +⭐ coef 원문 중복 — 「암괴…점토」 두 줄은 이름·값이 모두 같아 **한 줄로 합치고 사유**(브레인 ㉮) · + ⚠ 합친 줄인데 값이 갈리면 **빨강**(품셈 개정으로 두 줄이 달라지는 날 조용히 지나가면 안 됨). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Router as router_module + +NEW_KINDS = ( + "coef", + "material_surcharge", + "formwork_reuse", + "rebar_complexity", + "timber_structure_class", + "masonry_slope", + "masonry_back_length", + "stone_kind", + "masonry_class", +) + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + from common_util.common_util_auth import verify_session + + monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} + return TestClient(app) + + +def _get(client: TestClient, kind: str, **params) -> dict: + res = client.get(f"/api/master-data/base-prices/{kind}", params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _ids(table: dict) -> list[str]: + return [r["@id"] for r in table["rows"]] + + +def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None: + assert base_prices.KINDS == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS + for kind in NEW_KINDS: + table = _get(client, kind, size=500) + assert table["total"] == len(table["rows"]) > 0, kind + ids = _ids(table) + assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음 + assert table["editable"] and set(table["editable"]) <= { + c["key"] for c in table["columns"] + }, kind + assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind + axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())} + assert {table["locked"][c] for c in axis if c in table["locked"]} == { + "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" + }, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함 + assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind + assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로) + + +def test_값표만_태우고_이음표는_안_태움(client: TestClient) -> None: + formwork = " ".join(_ids(_get(client, "formwork_reuse", size=500))) + assert "type_map" not in formwork and "euroform_type/classes" in formwork + rebar = " ".join(_ids(_get(client, "rebar_complexity", size=500))) + assert "form_map" not in rebar and "classes/" in rebar + timber = " ".join(_ids(_get(client, "timber_structure_class", size=500))) + assert "type_map" not in timber + coef = " ".join(_ids(_get(client, "coef", size=500))) + assert "surcharge" not in coef and "rate_tool" not in coef # 품셈 원문 표·낱값은 여기 아님 + masonry = " ".join(_ids(_get(client, "masonry_class", size=500))) + assert "bond" not in masonry # 공종코드 잇는 표 + for gone in ("structure_unit_observed", "revetment_sabang"): + assert client.get(f"/api/master-data/base-prices/{gone}").status_code == 404 + + +def test_원문_중복_두_줄은_한_줄로_사유와_함께(client: TestClient) -> None: + rows = {r["@id"]: r for r in _get(client, "coef", size=500)["rows"]} + # 이름 글자가 다른 쌍은 그대로 둘 줄(값도 다름) + assert rows["coef_soil_L/역(礫)이 섞인 점질토"]["min"] == 1.35 + assert rows["coef_soil_L/역이 섞인 점질토"]["min"] == 1.3 + merged = rows["coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토"] + assert (merged["min"], merged["max"]) == (1.4, 1.45) + assert merged["source_rows"] == 2 and "원문 두 줄" in merged["duplicate_note"] + assert [i for i in rows if i.startswith("coef_soil_L/암괴(岩塊)")] == [ + "coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토" + ] # 두 줄이 한 줄로 + + +def test_합친_줄인데_값이_갈리면_빨강() -> None: + """품셈 개정으로 두 줄 값이 갈리는 날 — 조용히 한 줄로 합치면 틀린 값이 섬(브레인 조건).""" + same = [ + {"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1}, + {"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1}, + ] + merged = base_prices.merge_same_rows(same) + assert len(merged) == 1 and merged[0]["source_rows"] == 2 + differing = [ + {"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1}, + {"@id": "x", "soil_type": "가", "min": 1.2, "max": 1.3}, + ] + with pytest.raises(base_prices.DuplicateRowError): + base_prices.merge_same_rows(differing) + + +def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None: + rows = {r["@id"]: r for r in _get(client, "masonry_slope", size=500)["rows"]} + assert rows["table/메쌓기/성토/~1.5"]["face_slope_ratio"] == 0.3 + assert rows["table/찰쌓기/절토/7이상"]["face_slope_ratio"] == 0.4 + back = {r["@id"]: r for r in _get(client, "masonry_back_length", size=500)["rows"]} + assert (back["table_cm/메쌓기/~1.5"]["min_cm"], back["table_cm/메쌓기/~1.5"]["max_cm"]) == ( + 25, + 35, + ) + assert back["table_cm/메쌓기/7이상"]["max_cm"] is None # 원문 「-」 — 지어내지 않음 + stone = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]} + assert stone["wedge_stone_m3_per_m2/깬잡석/25"]["m3_per_m2"] == 0.09 + assert stone["fill_concrete_m3_per_m2/견치돌/75"]["m3_per_m2"] == 0.34 + assert stone["backfill_ratio_of_back_length/깬돌"]["ratio"] == 0.5 + ratio = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]} + assert ratio["reuse_ratio_pct/plywood/2"]["ratio_pct"] == 57.0 + assert ratio["reuse_by_class/복잡한 구조"]["reuse_count"] == 2 + timber = {r["@id"]: r for r in _get(client, "timber_structure_class", size=500)["rows"]} + assert timber["classes/보통구조 하"]["carpenter"] == 6.285 + surcharge = {r["@id"]: r for r in _get(client, "material_surcharge", size=500)["rows"]} + assert (surcharge["rates_pct/시멘트"]["rate"], surcharge["rates_pct/시멘트"]["alt_rate"]) == ( + 2, + 3, + ) + + +def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None: + res = client.put( + "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", + json={"values": {"m3_per_m2": 0.1}}, + ) + assert res.status_code == 200, res.text + row = res.json() + assert row["m3_per_m2"] == 0.1 + assert row["@overrides"]["m3_per_m2"]["original"] == 0.09 + assert (tmp_path / "data_master_override" / "stone_kind.json").is_file() + listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json() + assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"] + blocked = client.put( + "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", + json={"values": {"stone_kind": "딴 돌"}}, + ) + assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침 + assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄 + # 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함 + axis_edit = client.put( + "/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood/2", + json={"values": {"reuse_count": 3}}, + ) + assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text + value_edit = client.put( + "/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조", + json={"values": {"reuse_count": 3}}, + ) + assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3 From 75ae9375d993d14b092f64a11fce3ed38978cbfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 13:08:52 +0900 Subject: [PATCH 34/48] =?UTF-8?q?fix(z01):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=ED=95=A0=EC=A6=9D=20=EB=91=90=20=EB=B2=8C=20=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=EB=AA=85=EA=B8=B0=20=C2=B7=20coef=20=EC=95=94=EA=B4=B4=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B0=B8=EA=B3=A0=20=EC=82=AC=EB=A1=80=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EB=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **이원화가 아님을 두 자리에 못박음** — `material_surcharge.original_tables` 「원문 표는 coef/surcharge_mat」 · `coef.variables.surcharge_mat.used_value_at` 「읽어 쓰는 값은 material_surcharge/rates_pct」. 저쪽은 원문 보관이라 읽는 코드가 0 - **암괴 중복 두 줄** — L·C 둘 다 첫 줄에 `duplicate_same_value`, 둘째 줄에 `hidden_duplicate` + 사유 「원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠」. ⚠ 원본 줄은 안 지움 · `selection: pending` 과 「원문 오기 가능성」 주석 그대로 살림 ⚠ 「역이 섞인 점질토」는 값이 달라 안 접음(L 1.35~1.4 ↔ 1.3~1.4) - **참고 사례 갈래(`reference`) 새로 둠** — `structure_unit_observed`(울진소광 한 현장 관찰값) · `revetment_sabang`(교본값에 설명 글 섞임). 트리엔 남되 기초데이터가 아님(사용자 판정). 갈래 셈 로직 17 → 15 · 참고 사례 2 - 마스터 둘이 바뀌어 폴더 지문(`_manifest`) 둘 갱신 · 새 열 넷·값 묶음 둘 이름표 추가 - ⚠ `test_z01_master_data.py`(랩탑 메인)가 갈래 수를 박아 두어 함께 고침 — 데이터가 바뀌면 따라가야 하는 상수임 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_cost_input_value/_manifest.json | 4 +- .../data_cost_input_value/coef_2026.json | 11 +++ .../data_master_labels/labels_2026-01-01.json | 97 ++++++++++++++++--- .../data_material_surcharge/_manifest.json | 4 +- .../material_surcharge_2026-01-01.json | 1 + resources/tester/test_master_labels_cover.py | 4 +- resources/tester/test_z01_master_data.py | 10 +- 7 files changed, 114 insertions(+), 17 deletions(-) diff --git a/resources/data_cost_input_value/_manifest.json b/resources/data_cost_input_value/_manifest.json index 56945ecf..389c2456 100644 --- a/resources/data_cost_input_value/_manifest.json +++ b/resources/data_cost_input_value/_manifest.json @@ -13,8 +13,8 @@ "file": "coef_2026.json", "dataset_id": "coef", "effective_date": "2026-01-01", - "sha256": "0bc3fe981165e8d320b7c20f4535c545f2f54415427ac60205dac4a72dbb4f19", - "size_bytes": 29413 + "sha256": "daba170cd806374c92e086dec69f9559271c0317a7426e66e9dc841c2ca264bd", + "size_bytes": 31715 }, { "file": "fx_2026-08-14.json", diff --git a/resources/data_cost_input_value/coef_2026.json b/resources/data_cost_input_value/coef_2026.json index b2654f01..51cb6ef4 100644 --- a/resources/data_cost_input_value/coef_2026.json +++ b/resources/data_cost_input_value/coef_2026.json @@ -787,6 +787,7 @@ ] }, "surcharge_mat": { + "used_value_at": "resources/data_material_surcharge/material_surcharge_2026-01-01.json → rates_pct — ⚠ 코드가 읽어 쓰는 값은 저기다. 이 표는 원문 보관용이라 읽는 코드가 없다(두 벌이 아니다).", "unit": "percent", "representation": "full_source_tables", "duplicate_application_prohibited": true, @@ -1108,6 +1109,8 @@ "max": 1.45, "duplicate_group": "암괴나 호박돌이 섞인 점토", "duplicate_index": 1, + "duplicate_same_value": true, + "duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).", "selection": "pending" }, { @@ -1129,6 +1132,9 @@ "max": 1.45, "duplicate_group": "암괴나 호박돌이 섞인 점토", "duplicate_index": 2, + "duplicate_same_value": true, + "hidden_duplicate": true, + "duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).", "selection": "pending" } ] @@ -1210,6 +1216,8 @@ "max": 0.95, "duplicate_group": "암괴나 호박돌이 섞인 점토", "duplicate_index": 1, + "duplicate_same_value": true, + "duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).", "selection": "pending" }, { @@ -1231,6 +1239,9 @@ "max": 0.95, "duplicate_group": "암괴나 호박돌이 섞인 점토", "duplicate_index": 2, + "duplicate_same_value": true, + "hidden_duplicate": true, + "duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).", "selection": "pending" } ] diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 4607da7e..d43c8ff1 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,14 +2,14 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-16T11:39:55+09:00", + "generated_at": "2026-09-16T13:00:09+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", "no_invented_meaning": "뜻을 모르는 열은 name_ko 를 비우고 unknown 에 사유를 적는다. 지어내지 않는다.", "unit_is_label_only": "unit 은 이름표에서만 정한다. 자료에 없는 단위를 만들어 붙이지 않는다.", "counting_rule": "표 = 화면이 표로 그릴 마디(줄이 여럿인 마디). 값 묶음 = 표가 아닌 마디(설명·방침·한 값짜리 요율 따위).", - "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", + "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표에서 출발한다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1). 2026-09-16 사용자 판정으로 `structure_unit_observed`·`revetment_sabang` 둘을 **참고 사례**로 옮겼다 — 한 현장 관찰값·교본 설명 섞임이라 기초데이터가 아니다(로직 15 · 참고 사례 2).", "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", "merged_tables_rule": "`merged_tables` 는 **기초단가 다섯**(노임·자재·유가·제비율·기계)을 화면이 한 표씩으로 보이려고 여러 원본 표를 모은 자리다. ⚠ **값을 옮기지 않는다** — 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담는다. `editable: false` 는 다른 값에서 나온 계산값이라 사람이 못 고친다. 기계는 한 표로 안 모이므로 `machine_cost_chain` 이 사슬을 적는다 (2026-09-15 사용자 지시 · 브레인).", @@ -45,6 +45,10 @@ "name_ko": "지문", "summary": "어느 판으로 셈했는지 못박는 자리. 값도 규칙도 아니고 **판을 가리키는 표**다 — 고치지 않는다." }, + "reference": { + "name_ko": "참고 사례", + "summary": "한 현장·한 교본에서 본 값. ⚠ 표준이 아니다 — 근거로 보되 기초데이터처럼 갈아 끼우지 않는다." + }, "seed": { "name_ko": "씨앗", "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." @@ -1090,11 +1094,11 @@ "merged_columns": 89, "files": 34, "tables": 91, - "columns": 456, - "value_groups": 173, + "columns": 462, + "value_groups": 175, "unknown": 0, "value_groups_by_kind": { - "doc": 107, + "doc": 109, "value": 66 } }, @@ -1484,6 +1488,24 @@ "name_ko": "채택 여부", "unit": "", "visible": true + }, + { + "key": "duplicate_same_value", + "name_ko": "값 같은 중복", + "unit": "", + "visible": true + }, + { + "key": "duplicate_reason", + "name_ko": "중복 사유", + "unit": "", + "visible": true + }, + { + "key": "hidden_duplicate", + "name_ko": "접힌 줄", + "unit": "", + "visible": false } ] }, @@ -1529,6 +1551,24 @@ "name_ko": "채택 여부", "unit": "", "visible": true + }, + { + "key": "duplicate_same_value", + "name_ko": "값 같은 중복", + "unit": "", + "visible": true + }, + { + "key": "duplicate_reason", + "name_ko": "중복 사유", + "unit": "", + "visible": true + }, + { + "key": "hidden_duplicate", + "name_ko": "접힌 줄", + "unit": "", + "visible": false } ] }, @@ -1665,6 +1705,12 @@ "name_ko": "값 단위", "summary": "할증은 %다.", "kind": "value" + }, + { + "key": "variables/surcharge_mat/used_value_at", + "name_ko": "쓰는 값이 있는 자리", + "summary": "이 표는 원문 보관용이라 읽는 코드가 없다. 값은 `material_surcharge` 의 `rates_pct` 를 읽는다.", + "kind": "doc" } ] }, @@ -1827,6 +1873,12 @@ "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다.", "kind": "value" }, + { + "key": "original_tables", + "name_ko": "원문 표가 실린 자리", + "summary": "같은 값이 `coef` 의 `surcharge_mat` 에도 있다. ⚠ 두 벌이 아니다 — 저쪽은 원문 보관이고 읽어 쓰는 정본은 여기다.", + "kind": "doc" + }, { "key": "policy", "name_ko": "방침", @@ -2562,9 +2614,9 @@ { "file_id": "revetment_sabang", "path": "resources/data_masonry/revetment_sabang_2026-01-01.json", - "name_ko": "기슭막이 치수(사방)", - "summary": "독립 기슭막이의 높이·계획비탈·둑마루 두께·뒷채움을 한 자리에 둔 벌.", - "kind": "logic", + "name_ko": "기슭막이 치수(사방) — 참고 사례", + "summary": "사방기술교본 값에 설명 글이 섞인 벌. ⚠ 기초데이터가 아니다 — 표준값처럼 쓰지 말고 근거로만 본다(2026-09-16 사용자 판정).", + "kind": "reference", "tables": [], "value_groups": [ { @@ -2596,9 +2648,9 @@ { "file_id": "structure_unit_observed", "path": "resources/data_structure_unit/structure_unit_observed_2026-01-01.json", - "name_ko": "구조물 관측 원단위", - "summary": "품셈에 표준 물량표가 없는 콘크리트 구조물을 실무 설계원본에서 뽑은 벌.", - "kind": "logic", + "name_ko": "구조물 관측 원단위 — 참고 사례", + "summary": "울진소광 **한 현장**을 관찰한 값이다. ⚠ 기초데이터가 아니다 — 규격이 다르면 안 맞고 표준도 아니다(2026-09-16 사용자 판정).", + "kind": "reference", "tables": [ { "key": "double_count_rules/rules", @@ -6320,6 +6372,16 @@ "unit": "", "visible": true }, + "duplicate_reason": { + "name_ko": "중복 사유", + "unit": "", + "visible": true + }, + "duplicate_same_value": { + "name_ko": "값 같은 중복", + "unit": "", + "visible": true + }, "duration_bracket": { "name_ko": "공사기간 구간", "unit": "", @@ -6470,6 +6532,11 @@ "unit": "", "visible": true }, + "hidden_duplicate": { + "name_ko": "접힌 줄", + "unit": "", + "visible": false + }, "hours_per_day": { "name_ko": "1일 근로시간", "unit": "시간", @@ -7479,6 +7546,10 @@ "name_ko": "값 단위", "summary": "할증은 %다." }, + "coef::variables/surcharge_mat/used_value_at": { + "name_ko": "쓰는 값이 있는 자리", + "summary": "이 표는 원문 보관용이라 읽는 코드가 없다. 값은 `material_surcharge` 의 `rates_pct` 를 읽는다." + }, "formwork_reuse::reuse_ratio_pct": { "name_ko": "사용횟수별 기준수량 비율", "summary": "품셈 12-4 — 일위대가 재료비에 걸린다. B08 이 곱하면 이중계상이다." @@ -7599,6 +7670,10 @@ "name_ko": "실무 관측", "summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다." }, + "material_surcharge::original_tables": { + "name_ko": "원문 표가 실린 자리", + "summary": "같은 값이 `coef` 의 `surcharge_mat` 에도 있다. ⚠ 두 벌이 아니다 — 저쪽은 원문 보관이고 읽어 쓰는 정본은 여기다." + }, "oil::variables/oil_diesel": { "name_ko": "경유 단가", "summary": "전국평균 경유 값(원/L)." diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index 31b2bde7..a8192720 100644 --- a/resources/data_material_surcharge/_manifest.json +++ b/resources/data_material_surcharge/_manifest.json @@ -6,8 +6,8 @@ "files": [ { "file": "material_surcharge_2026-01-01.json", - "sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f", - "size_bytes": 5087 + "sha256": "1394a39be22ac0eb7476e4bc2b81c1e23c365a4ad5b915befe85d3f0f99023d5", + "size_bytes": 5344 } ] } diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index 2e732b1e..03a971a1 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -1,6 +1,7 @@ { "schema_version": "1.0", "dataset_id": "material_surcharge", + "original_tables": "resources/data_cost_input_value/coef_2026.json → variables/surcharge_mat — 원문 표가 실린 자리. ⚠ 값이 겹치지만 이원화가 아니다: 저쪽은 원문 보관이고 읽어 쓰는 정본은 여기(rates_pct)다.", "effective_date": "2026-01-01", "source": { "primary": { diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 8e5504f9..8f08bcd7 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -19,7 +19,9 @@ ROOT = Path(__file__).resolve().parents[2] LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" #: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다. -BRAIN_KIND_COUNTS = {"logic": 17, "base_value": 9, "byproduct": 4, "seed": 1} +#: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와 +#: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다. +BRAIN_KIND_COUNTS = {"logic": 15, "base_value": 9, "byproduct": 4, "seed": 1, "reference": 2} MANIFEST_PREFIX = "manifest_" META_KEYS = { diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index f7f00f68..3814a58e 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -49,7 +49,15 @@ def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: Te groups = _tree(client)["groups"] assert [g["key"] for g in groups] == [k for k in LABELS["kinds"]] counts = {g["key"]: len(g["files"]) for g in groups} - assert counts == {"logic": 17, "base_value": 9, "byproduct": 4, "fingerprint": 3, "seed": 1} + # 2026-09-16 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다. + assert counts == { + "logic": 15, + "base_value": 9, + "byproduct": 4, + "reference": 2, + "fingerprint": 3, + "seed": 1, + } assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값" by_id = {f["file_id"]: f for f in LABELS["files"]} for f in (f for g in groups for f in g["files"]): From ede97d30fbc4c56759f06b4c4bcff8856fd9b170 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 13:12:58 +0900 Subject: [PATCH 35/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=95=84=ED=99=89=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=E2=80=94=20=EC=97=B4=2021=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=EC=97=B4=EC=87=A0=20=EC=95=84=ED=99=89=20=C2=B7=20?= =?UTF-8?q?=EC=B6=95=20=EC=8B=9C=ED=97=98=20=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kind 아홉을 이름표 `merged_tables` 에 더함(다섯 → **열넷**) — coef · material_surcharge · formwork_reuse · rebar_complexity · timber_structure_class · masonry_slope · masonry_back_length · stone_kind · masonry_class. 영문으로 뜨던 21칸 **0** - ⚠ 구간 이름은 **원문 표기 그대로**(`~1.5`·`~3`·`~5`·`~7`·`7이상` · 돌 직경 `40~60`) — 열쇠의 몸이라 예쁘게 고치면 덮개가 주인을 잃음. 시험이 그 표기를 박아 둠 - 축 칸(`table`·`bond`·`face`·`height_bracket`·`stone_kind`·`back_length_cm`·`class`· `soil_type`·`type_id`·`material`)은 이름에 「줄을 가리는 축 — 못 고침」을 적음 - `coef/source_rows`·`duplicate_note` 는 암괴 중복 두 줄을 한 줄로 합친 사유 칸으로 이름 붙임 - 줄 열쇠 아홉을 `row_keys` 에 더함 — 서버가 줄마다 내는 `@axis` 가 곧 열쇠 · 넷은 **id 칸이 없어 축으로만 가려짐**(masonry_slope·masonry_back_length·stone_kind·masonry_class) - 시험 셋 더함(12건) — 이름표 축이 서버 `@axis` 를 다 덮는지 · 구간 표기가 원문 그대로인지 · 축 칸이 고칠 수 있는 칸으로 서 있지 않은지 - ⚠ 예외 하나 적어 둠 — `formwork_reuse.reuse_count` 는 갈래표에서는 **값**, 비율표에서는 **축**이라 서버가 한 판정만 내는 지금은 고칠 수 있는 칸으로 섬(판정 대기) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 526 +++++++++++++++++- .../tester/test_master_labels_base_prices.py | 57 ++ resources/tester/test_master_labels_cover.py | 23 +- 3 files changed, 600 insertions(+), 6 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index d43c8ff1..c91f964b 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-16T13:00:09+09:00", + "generated_at": "2026-09-16T13:11:45+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -770,6 +770,433 @@ "note": "못 채운 성분이 있으면 그 사실을 적는 자리 — 0 으로 안 때운다." } ] + }, + { + "key": "coef", + "name_ko": "토량환산계수", + "summary": "판 흙이 부풀고(L) 다진 흙이 줄어드는(C) 비. 토질마다 하한·상한.", + "sources": [ + "coef::variables/coef_soil_L/records", + "coef::variables/coef_soil_C/records" + ], + "rows_note": "L 16 + C 16 = 32 줄(암괴 중복 두 줄은 한 줄로 합침)", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`coef_soil_L`(부풂) · `coef_soil_C`(다짐). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "soil_type", + "name_ko": "토질", + "unit": "", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "min", + "name_ko": "하한", + "unit": "", + "visible": true + }, + { + "key": "max", + "name_ko": "상한", + "unit": "", + "visible": true + }, + { + "key": "rule", + "name_ko": "별도 규정", + "unit": "", + "visible": true, + "note": "원문이 값 대신 「별도 설계」라 적은 자리." + }, + { + "key": "selection", + "name_ko": "채택 여부", + "unit": "", + "visible": true, + "note": "원문이 같은 토질을 두 줄로 실어 어느 줄을 쓸지 아직 안 고른 자리(`pending`)." + }, + { + "key": "source_rows", + "name_ko": "원문 줄 수", + "unit": "줄", + "visible": true, + "note": "⭐ 한 줄로 보이지만 원문에는 몇 줄이었나 — 암괴 중복 쌍이 `2` 다." + }, + { + "key": "duplicate_note", + "name_ko": "합친 사유", + "unit": "", + "visible": true, + "note": "⭐ 「원문 두 줄 · 값 같음 — 한 줄로 보임」. 원문은 그대로 두고 화면만 접었다는 뜻." + } + ] + }, + { + "key": "material_surcharge", + "name_ko": "재료 할증률", + "summary": "자재마다 몇 %를 더 사는지. 조건이 갈리면 다른 조건 칸에 함께 둔다.", + "sources": [ + "material_surcharge::rates_pct" + ], + "rows_note": "19 줄", + "columns": [ + { + "key": "material", + "name_ko": "자재명", + "unit": "", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "rate", + "name_ko": "할증률", + "unit": "%", + "visible": true + }, + { + "key": "condition", + "name_ko": "조건", + "unit": "", + "visible": true + }, + { + "key": "alt_rate", + "name_ko": "다른 조건 할증률", + "unit": "%", + "visible": true + }, + { + "key": "alt_condition", + "name_ko": "다른 조건", + "unit": "", + "visible": true + }, + { + "key": "pumsem", + "name_ko": "어느 품셈", + "unit": "", + "visible": true, + "note": "`forest`(산림 · 1차) · `construction`(건설 · 보완)." + } + ] + }, + { + "key": "formwork_reuse", + "name_ko": "거푸집 사용횟수", + "summary": "구조물 갈래마다 거푸집을 몇 번 쓰는지, 횟수마다 기준수량의 몇 %인지.", + "sources": [ + "formwork_reuse::reuse_by_class", + "formwork_reuse::reuse_ratio_pct", + "formwork_reuse::euroform_type/classes" + ], + "rows_note": "구조 갈래 4 + 사용횟수 비율 12(합판·각재 × 6) + 유로폼 갈래 3 = 19 줄", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`reuse_by_class`(갈래별 횟수) · `reuse_ratio_pct`(횟수별 비율) · `euroform_type/classes`(유로폼 갈래). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "class", + "name_ko": "구조 갈래", + "unit": "", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "reuse_count", + "name_ko": "사용횟수", + "unit": "회", + "visible": true, + "note": "비율표에서는 ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + }, + { + "key": "material", + "name_ko": "거푸집 재료", + "unit": "", + "visible": true, + "note": "`plywood`(합판) · `timber`(각재). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "ratio_pct", + "name_ko": "기준수량 비율", + "unit": "%", + "visible": true, + "note": "⭐ 품셈 12-4 — 그 횟수로 쓸 때 기준수량의 몇 %인지. 일위대가 재료비에 걸린다." + }, + { + "key": "daily_area_m2", + "name_ko": "1일 시공량", + "unit": "㎡", + "visible": true, + "note": "유로폼 설치·해체 품이 갈리는 값." + } + ] + }, + { + "key": "rebar_complexity", + "name_ko": "철근 가공·조립 갈래", + "summary": "간단·보통·복잡·매우복잡 갈래와 갈래별 단가 참고값.", + "sources": [ + "rebar_complexity::classes", + "rebar_complexity::price_hint_krw_per_ton" + ], + "rows_note": "갈래 4 + 단가 참고값 4 = 8 줄", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`classes`(갈래) · `price_hint_krw_per_ton`(단가 참고값). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true, + "note": "품셈 12-3 [주]① 이 든 구조물 보기." + }, + { + "key": "price_krw_per_ton", + "name_ko": "단가 참고값", + "unit": "원/ton", + "visible": true, + "note": "⚠ **표시 전용** — 갈래 차이를 사람이 보라고 둔 값이다. 계산에 안 들어간다." + } + ] + }, + { + "key": "timber_structure_class", + "name_ko": "목재공작물 구조 갈래", + "summary": "갈래마다 목재 채적 1㎥ 에 드는 건축목공·보통인부.", + "sources": [ + "timber_structure_class::classes" + ], + "rows_note": "6 줄", + "columns": [ + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true, + "note": "보통구조 하·중·상 · 상등구조 하·중·상. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "carpenter", + "name_ko": "건축목공", + "unit": "인/㎥", + "visible": true + }, + { + "key": "laborer", + "name_ko": "보통인부", + "unit": "인/㎥", + "visible": true + }, + { + "key": "examples", + "name_ko": "원문 예시", + "unit": "", + "visible": true + } + ] + }, + { + "key": "masonry_slope", + "name_ko": "돌쌓기 표준경사", + "summary": "쌓기 방식 × 성절토 × 직고 구간마다 전면 기울기(1:n).", + "sources": [ + "masonry_slope::table" + ], + "rows_note": "메찰 2 × 성절토 2 × 직고 5 = 20 줄", + "columns": [ + { + "key": "bond", + "name_ko": "쌓기 방식", + "unit": "", + "visible": true, + "note": "메쌓기 · 찰쌓기. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "face", + "name_ko": "성토·절토", + "unit": "", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "height_bracket", + "name_ko": "직고 구간", + "unit": "m", + "visible": true, + "note": "⚠ 원문 표기 그대로(`~1.5`·`~3`·`~5`·`~7`·`7이상`) — 예쁘게 고치면 열쇠가 바뀐다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "face_slope_ratio", + "name_ko": "전면 기울기", + "unit": "1:n", + "visible": true, + "note": "코드가 늘 쓰던 0.3 은 이 표의 「메쌓기·성토·~1.5」 한 칸일 뿐이었다." + } + ] + }, + { + "key": "masonry_back_length", + "name_ko": "돌쌓기 뒷길이 표준", + "summary": "쌓기 방식 × 직고 구간마다 뒷길이 하한·상한(㎝).", + "sources": [ + "masonry_back_length::table_cm" + ], + "rows_note": "메찰 2 × 직고 5 = 10 줄", + "columns": [ + { + "key": "bond", + "name_ko": "쌓기 방식", + "unit": "", + "visible": true, + "note": "메쌓기 · 찰쌓기. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "height_bracket", + "name_ko": "직고 구간", + "unit": "m", + "visible": true, + "note": "⚠ 원문 표기 그대로(`~1.5`·`~3`·`~5`·`~7`·`7이상`) — 예쁘게 고치면 열쇠가 바뀐다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "min_cm", + "name_ko": "뒷길이 하한", + "unit": "㎝", + "visible": true, + "note": "⭐ 안 고르면 이 값이 제안된다(범위 안에 드는 품셈 규격 중 가장 작은 것)." + }, + { + "key": "max_cm", + "name_ko": "뒷길이 상한", + "unit": "㎝", + "visible": true, + "note": "원문이 상한을 안 준 칸은 빈다." + } + ] + }, + { + "key": "stone_kind", + "name_ko": "돌 종류별 소요량", + "summary": "돌 종류 × 뒷길이마다 고임돌·채움 콘크리트 원단위와 뒤채움 몫.", + "sources": [ + "stone_kind::wedge_stone_m3_per_m2", + "stone_kind::fill_concrete_m3_per_m2", + "stone_kind::backfill_ratio_of_back_length", + "stone_kind::fallback" + ], + "rows_note": "고임돌 28 + 채움 28 + 뒤채움 몫 4 + 미지정일 때 15 = 75 줄", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`wedge_stone_m3_per_m2`(고임돌) · `fill_concrete_m3_per_m2`(채움 콘크리트) · `backfill_ratio_of_back_length`(뒤채움 몫) · `fallback/…`(돌 종류 미지정일 때). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "stone_kind", + "name_ko": "돌 종류", + "unit": "", + "visible": true, + "note": "야면석·호박돌 / 깬잡석 / 깬돌 / 견치돌. 미지정 벌에는 이 칸이 없다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "back_length_cm", + "name_ko": "뒷길이", + "unit": "㎝", + "visible": true, + "note": "25·30·35·45·55·60·75 일곱 규격. ⚠ 표에 없는 뒷길이를 가까운 칸으로 접지 않는다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "m3_per_m2", + "name_ko": "원단위", + "unit": "㎥/㎡", + "visible": true, + "note": "고임돌·채움 콘크리트 표의 값. 원문 「-」 칸은 빈다 — 0 이 아니다." + }, + { + "key": "ratio", + "name_ko": "뒤채움 몫", + "unit": "", + "visible": true, + "note": "뒷길이 가운데 뒤채움이 차지하는 비. 나머지가 잡석이 된다." + } + ] + }, + { + "key": "masonry_class", + "name_ko": "돌쌓기 규격 갈래", + "summary": "저장 제원으로 자동 판정하는 축 — 뒷길이 갈래·돌 직경 갈래·형식별 전면 기울기.", + "sources": [ + "masonry_class::back_length/classes", + "masonry_class::boulder_diameter", + "masonry_class::face_slope" + ], + "rows_note": "뒷길이 갈래 3 + 돌 직경 갈래 3 + 형식별 기울기 3 = 9 줄", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`back_length/classes`(뒷길이 갈래) · `boulder_diameter`(돌 직경 갈래) · `face_slope`(형식별 기울기). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "class", + "name_ko": "갈래", + "unit": "", + "visible": true, + "note": "뒷길이는 `35cm이하` 꼴, 돌 직경은 원문 표기 `40~60` 꼴. ⚠ 원문 표기 그대로(`~1.5`·`~3`·`~5`·`~7`·`7이상`) — 예쁘게 고치면 열쇠가 바뀐다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "max_cm", + "name_ko": "상한", + "unit": "㎝", + "visible": true, + "note": "뒷길이 갈래의 상한." + }, + { + "key": "type_id", + "name_ko": "구조물 종류 키", + "unit": "", + "visible": true, + "note": "`masonry_wet`·`masonry_dry`·`boulder_masonry`. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "face_slope_ratio", + "name_ko": "전면 기울기", + "unit": "1:n", + "visible": true, + "note": "형식마다 정해진 값. 코드 기본 0.3 이 여기서 왔다." + } + ] } ], "machine_cost_chain": { @@ -997,6 +1424,99 @@ "machine_code" ], "checked": "취득가 613 · 손료계수 387 · 연료 214 · 운전경비 92 **네 표 모두 기계코드 겹침 0**(전수). 취득가가 가장 넓어 뼈대가 되고 나머지가 붙는다." + }, + { + "table": "coef", + "name_ko": "토량환산계수", + "verdict": "있음(묶어야 함)", + "key": [ + "table", + "soil_type" + ], + "checked": "L·C 두 표가 한 자리에 서므로 `table` 이 함께 있어야 한다. 토질 이름은 원문 그대로. 암괴 중복 두 줄은 값이 같아 한 줄로 합쳤고 `source_rows`·`duplicate_note` 가 그 사유를 든다." + }, + { + "table": "material_surcharge", + "name_ko": "재료 할증률", + "verdict": "있음", + "key": [ + "material" + ], + "checked": "자재명 19 줄 겹침 0(전수). 그대로 열쇠로 쓴다." + }, + { + "table": "formwork_reuse", + "name_ko": "거푸집 사용횟수", + "verdict": "있음(표마다 다름)", + "key": [ + "table", + "class", + "material", + "reuse_count" + ], + "checked": "갈래 표는 `table+class`, 비율표는 `table+material+reuse_count` 로 가려진다. 한 표 안에서 축이 갈리므로 열쇠도 줄마다 다르다." + }, + { + "table": "rebar_complexity", + "name_ko": "철근 갈래", + "verdict": "있음(묶어야 함)", + "key": [ + "table", + "class" + ], + "checked": "갈래 표와 단가 참고값 표가 같은 갈래 이름을 쓰므로 `table` 이 함께 있어야 한다." + }, + { + "table": "timber_structure_class", + "name_ko": "목재 구조 갈래", + "verdict": "있음", + "key": [ + "class" + ], + "checked": "갈래 6 줄 겹침 0(전수)." + }, + { + "table": "masonry_slope", + "name_ko": "돌쌓기 표준경사", + "verdict": "없음 — 축으로만 가려짐", + "key": [ + "bond", + "face", + "height_bracket" + ], + "checked": "id 칸이 없다. 쌓기 방식 × 성절토 × 직고 구간 셋으로만 줄이 가려진다. ⚠ 직고 구간은 원문 표기 `~1.5`·`~3`·`~5`·`~7`·`7이상` 그대로 — 열쇠의 몸이라 못 바꾼다." + }, + { + "table": "masonry_back_length", + "name_ko": "돌쌓기 뒷길이 표준", + "verdict": "없음 — 축으로만 가려짐", + "key": [ + "bond", + "height_bracket" + ], + "checked": "id 칸이 없다. 쌓기 방식 × 직고 구간 둘로만 가려진다. 구간 표기는 위와 같다." + }, + { + "table": "stone_kind", + "name_ko": "돌 종류별 소요량", + "verdict": "없음 — 축으로만 가려짐", + "key": [ + "table", + "stone_kind", + "back_length_cm" + ], + "checked": "id 칸이 없다. 표 × 돌 종류 × 뒷길이로 가려지되 **표마다 축 수가 다르다** — 뒤채움 몫은 돌 종류까지, 미지정 벌은 뒷길이까지만 쓴다." + }, + { + "table": "masonry_class", + "name_ko": "돌쌓기 규격 갈래", + "verdict": "없음 — 축으로만 가려짐", + "key": [ + "table", + "class", + "type_id" + ], + "checked": "id 칸이 없다. 뒷길이·돌 직경 갈래는 `table+class`, 형식별 기울기는 `table+type_id`. ⚠ 돌 직경 갈래 이름은 원문 표기 `40~60` 꼴 그대로다." } ], "null_is_real": { @@ -1090,8 +1610,8 @@ } }, "counts": { - "merged_tables": 5, - "merged_columns": 89, + "merged_tables": 14, + "merged_columns": 136, "files": 34, "tables": 91, "columns": 462, diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index 801bd9b0..92a36288 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -152,3 +152,60 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown): if units.get(key) != unit: bad.append(f"{kind}/{key}: {units.get(key)!r} ≠ {unit!r}") assert not bad, bad + + +def test_줄_열쇠가_서버_축과_같다(labels): + """⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다. + + 이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다. + """ + declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]} + bad = [] + for kind in base_prices.KINDS: + axes = set() + for row in base_prices.rows(kind): + axes |= set(row.get("@axis") or ()) + if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다 + continue + missing = axes - declared.get(kind, set()) + if missing: + bad.append(f"{kind}: 이름표에 없는 축 {sorted(missing)}") + assert not bad, bad + + +def test_구간_이름은_원문_표기_그대로다(): + """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다.""" + for kind in ("masonry_slope", "masonry_back_length"): + brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)} + assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets)) + classes = { + str(r.get("class")) + for r in base_prices.rows("masonry_class") + if r.get("table") == "boulder_diameter" + } + assert classes == {"40~60", "60~80", "80~100"}, sorted(classes) + + +#: ⚠ **줄마다 축이기도 값이기도 한 칸** — `formwork_reuse.reuse_count` 는 +#: `reuse_by_class` 줄에서는 **고치는 값**(갈래별 사용횟수)이고 +#: `reuse_ratio_pct` 줄에서는 **축**(몇 회째인가)이다. 서버는 열 하나에 한 판정만 내므로 +#: 지금은 고칠 수 있는 칸으로 서 있다. 비율표 줄에서 이 칸을 고치면 **다른 줄로 옮겨 간다**. +#: 2026-09-16 크로스체크에서 드러났고 판정 대기 — 고치기 전까지 여기 예외로 적어 둔다. +AXIS_ALSO_VALUE = {"formwork_reuse/reuse_count"} + + +def test_축_칸은_못_고친다(): + """축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다.""" + bad = [] + for kind in base_prices.KINDS: + rows = base_prices.rows(kind) + axes = set() + for row in rows: + axes |= set(row.get("@axis") or ()) + if not axes: + continue + meta = base_prices.table(kind, page=1, size=1) + for axis in axes: + if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: + bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") + assert not bad, bad diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 8f08bcd7..9a9a9c4a 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -451,12 +451,29 @@ def test_미결은_미판정으로_표시된다(labels): assert column["name_ko"] == "미판정" -MERGED_KEYS = ("labor", "material", "oil", "rate", "machine") +#: 2026-09-16 사용자 판정 — 기초데이터 아홉이 더해져 열넷. +#: 잣대 = 계산되는 값이 아니라 **그냥 값만으로 정의되는 데이터셋**. +MERGED_KEYS = ( + "labor", + "material", + "oil", + "rate", + "machine", + "coef", + "material_surcharge", + "formwork_reuse", + "rebar_complexity", + "timber_structure_class", + "masonry_slope", + "masonry_back_length", + "stone_kind", + "masonry_class", +) -def test_기초단가_다섯이_다_있다(labels): +def test_기초데이터_열넷이_다_있다(labels): merged = {m["key"]: m for m in labels["merged_tables"]} - assert tuple(merged) == MERGED_KEYS + assert set(merged) == set(MERGED_KEYS) for entry in merged.values(): assert entry["name_ko"].strip() assert entry["summary"].strip() From 566e0c885e67a26354bc816faa2851723acefdcc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 14:03:03 +0900 Subject: [PATCH 36/48] =?UTF-8?q?feat(z01):=20=EC=A2=8C=EC=B8=A1=EC=9D=84?= =?UTF-8?q?=20=EB=91=98=EB=A1=9C=20=EA=B0=80=EB=A6=84=20=E2=80=94=20?= =?UTF-8?q?=E2=91=A0=EA=B8=B0=EC=B4=88=EB=8B=A8=EA=B0=80=20=EB=8B=A4?= =?UTF-8?q?=EC=84=AF(=EC=96=BC=EB=A7=88=EC=9D=B8=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=A5=EC=9D=B4=20=EC=A0=95=ED=95=A8)=20=E2=91=A1?= =?UTF-8?q?=ED=92=88=EC=85=88=20=EA=B8=B0=EC=A4=80=20=EC=95=84=ED=99=89(?= =?UTF-8?q?=EC=96=BC=EB=A7=88=EB=82=98=20=EB=93=9C=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=ED=92=88=EC=85=88=EC=9D=B4=20=ED=95=B4=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=C2=B7=20=ED=86=A0=EB=9F=89=ED=99=98?= =?UTF-8?q?=EC=82=B0=C2=B7=EC=9E=90=EC=9E=AC=ED=95=A0=EC=A6=9D=C2=B7?= =?UTF-8?q?=EA=B1=B0=ED=91=B8=EC=A7=91=C2=B7=EC=B2=A0=EA=B7=BC=C2=B7?= =?UTF-8?q?=EB=AA=A9=EA=B5=AC=EC=A1=B0=C2=B7=EB=8F=8C=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=C2=B7=EB=92=B7=EA=B8=B8=EC=9D=B4=C2=B7?= =?UTF-8?q?=EB=8F=8C=EC=A2=85=EB=A5=98=C2=B7=EA=B0=88=EB=9E=98)=20=C2=B7?= =?UTF-8?q?=20=EC=B6=95=20=EC=B9=B8=20=EC=9E=A0=EA=B8=88=C2=B7=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EC=9D=B4=EB=A6=84=EC=9D=80=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=A4=80=20=EB=8C=80=EB=A1=9C=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C(=EC=82=AC=EC=9A=A9=EC=9E=90=20=E2=91=A0=E2=91=A1=20?= =?UTF-8?q?=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 21 ++++++++- Z01_MasterData/Z01_MasterData_UI_Side.ts | 51 ++++++++++++++-------- ui_template/ui_template_locale_b3.ts | 11 +++++ 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 7d881ee6..7a76b9e7 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -47,9 +47,26 @@ export interface RowsQuery { sort?: SortOrder | null; } -/** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */ +/** + * ① 기초단가 다섯 — 얼마인가. 시장이 정하고 주간~반기로 바뀜. + * 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). + */ export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; -export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number]; + +/** ② 품셈 기준 아홉 — 얼마나 드나. 품셈이 정하고 해마다 한 번 개정(2026-09-16 사용자 ①②). */ +export const SPEC_KINDS = [ + "coef", + "material_surcharge", + "formwork_reuse", + "rebar_complexity", + "timber_structure_class", + "masonry_slope", + "masonry_back_length", + "stone_kind", + "masonry_class", +] as const; + +export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number] | (typeof SPEC_KINDS)[number]; async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index c50d6fed..a0fb7fee 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -15,9 +15,11 @@ import { fetchMasterRows, fetchOverrides, type MasterGroup, + type BasePriceKind, type MasterRows, type RowsQuery, saveBasePrice, + SPEC_KINDS, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; import { valueWithUnit, type OverrideState } from "./Z01_MasterData_UI_Cells"; @@ -31,25 +33,32 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme }; const baseTitle = L("Z01_MasterData_BasePrices"); - const baseGrid = el("div", { className: "z01-master__base-grid" }); - for (const kind of BASE_PRICE_KINDS) { - const label = L(`Z01_MasterData_Base_${kind}` as const); - const button = el("button", { - className: "z01-master__base", - attrs: { type: "button" }, - text: label, - }); - button.addEventListener("click", () => { - activate(button); - view.show({ - title: `${baseTitle} › ${label}`, - key: `base-prices/${kind}`, - load: (query) => fetchBasePrices(kind, query), - save: (rowId, values) => saveBasePrice(kind, rowId, values), + const specTitle = L("Z01_MasterData_SpecValues"); + /** 한 상자 몫 단추들 — ①얼마인가 ②얼마나 드나 로 갈라 세움(성격도 갱신 주기도 다름). */ + const kindGrid = (boxTitle: string, kinds: readonly BasePriceKind[]): HTMLElement => { + const grid = el("div", { className: "z01-master__base-grid" }); + for (const kind of kinds) { + const label = L(`Z01_MasterData_Base_${kind}` as const); + const button = el("button", { + className: "z01-master__base", + attrs: { type: "button" }, + text: label, }); - }); - baseGrid.append(button); - } + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${boxTitle} › ${label}`, + key: `base-prices/${kind}`, + load: (query) => fetchBasePrices(kind, query), + save: (rowId, values) => saveBasePrice(kind, rowId, values), + }); + }); + grid.append(button); + } + return grid; + }; + const baseGrid = kindGrid(baseTitle, BASE_PRICE_KINDS); + const specGrid = kindGrid(specTitle, SPEC_KINDS); const overridesButton = el("button", { className: "z01-master__base z01-master__base--wide", attrs: { type: "button" }, @@ -113,7 +122,11 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme tree.append(groupBox); } - panel.append(section(baseTitle, baseGrid), section(L("Z01_MasterData_Tree"), tree)); + panel.append( + section(baseTitle, baseGrid), + section(specTitle, specGrid), + section(L("Z01_MasterData_Tree"), tree), + ); attachCollapsible(panel); return panel; } diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 662fc14c..866bf245 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -147,6 +147,17 @@ export const ui_locales_b3 = { Z01_MasterData_Base_material: ["자재단가", "Material"], Z01_MasterData_Base_oil: ["유가", "Fuel"], Z01_MasterData_Base_rate: ["요율", "Rates"], + /* ② 품셈 기준 아홉 — 얼마나 드나(품셈이 정하고 해마다 한 번 개정) */ + Z01_MasterData_SpecValues: ["품셈 기준", "Standard rates"], + Z01_MasterData_Base_coef: ["토량환산", "Soil conversion"], + Z01_MasterData_Base_material_surcharge: ["자재할증", "Material surcharge"], + Z01_MasterData_Base_formwork_reuse: ["거푸집", "Formwork reuse"], + Z01_MasterData_Base_rebar_complexity: ["철근", "Rebar"], + Z01_MasterData_Base_timber_structure_class: ["목구조", "Timber structure"], + Z01_MasterData_Base_masonry_slope: ["돌쌓기 경사", "Masonry slope"], + Z01_MasterData_Base_masonry_back_length: ["뒷길이", "Back length"], + Z01_MasterData_Base_stone_kind: ["돌종류", "Stone kind"], + Z01_MasterData_Base_masonry_class: ["갈래", "Masonry class"], Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", From 7f2e22158577448ddf4b51e17dba8151ea2b5e8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 14:06:14 +0900 Subject: [PATCH 37/48] =?UTF-8?q?feat(z01):=20=EC=B6=9C=EC=B2=98=ED=91=9C?= =?UTF-8?q?=20=EB=B9=88=EC=B9=B8=20=EC=9D=BC=EA=B3=B1=20=EC=B1=84=EC=9B=80?= =?UTF-8?q?=20=E2=80=94=20=EB=B0=9B=EB=8A=94=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EB=B9=88=20=EC=B9=B8=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 안티그래비티가 찾아 준 주소 일곱(2026-09-16) — 제조노임(중소기업중앙회 kbiz 게시판 mnSeq=325) · 기계·기계 운전경비(공사비원가관리센터 cost.kict.re.kr · CODIL 대체) · 자재(나라장터 g2b 가격정보) · 유가 전국·지역(오피넷 평균판매가격 · API avgAllPrice/avgSidoPrice) · 요율(조달청 시설공사 제비율) - 채운 줄은 `checked_on` 을 2026-09-16 으로 · 건설노임 한 줄은 2026-09-15 그대로 - 주소가 온 자리를 `policy.address_found_on` 에 적음 · 「주소를 지어내지 않는다」 방침은 그대로 둠 - 제조노임 비고에서 「받는 자리를 아직 못 받았다」 문구를 걷고 「최신 공표일은 게시판에서 봐야 함」으로 바꿈 - ⚠ 최신 공표일은 여전히 넷이 모름 — 자재·유가 셋은 수시·일별이라 한 날로 안 정해짐(주소와 별개) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../sources_2026-09-15.json | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/resources/data_master_sources/sources_2026-09-15.json b/resources/data_master_sources/sources_2026-09-15.json index ec955a7e..09287064 100644 --- a/resources/data_master_sources/sources_2026-09-15.json +++ b/resources/data_master_sources/sources_2026-09-15.json @@ -2,11 +2,12 @@ "schema_version": "1.0", "dataset_id": "data_master_sources", "effective_date": "2026-09-15", - "generated_at": "2026-09-15T21:59:23+09:00", + "generated_at": "2026-09-16T14:05:40+09:00", "note": "기초단가 자료가 **어디서 오고 얼마 만에 바뀌는지** 적은 표. 파일 안에는 제 판만 있고 「최신이 무엇인지·어디서 받는지」가 없어 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). 그 자리를 메운다.", "policy": { "values_untouched": "값을 담지 않는다. 자료가 어디서 오는지만 담는다.", "our_edition_is_read": "`our_edition` 은 **실제 파일에서 읽는다**(`effective_date`, 파생본은 `derived_from.effective_date`). 손으로 적으면 갱신하고 안 고쳐 또 뒤처진다.", + "address_found_on": "받는 자리 일곱은 2026-09-16 에 안티그래비티가 찾아 채웠다(제조노임·기계·기계 운전경비·자재·유가 전국·유가 지역·요율). 건설노임 한 줄은 그 전(2026-09-15)에 브레인이 준 것이다.", "no_invented_address": "받는 자리는 **받은 것만** 적는다. 모르면 빈 칸으로 두고 사유를 단다 — 주소를 지어내지 않는다.", "status_rule": "`latest_published` 가 `our_edition` 보다 뒤면 **뒤처짐**, 같으면 최신, 둘 가운데 하나라도 없으면 **모름**. 날마다 바뀌는 유가는 날짜 차이로 보여야 한다." }, @@ -65,19 +66,18 @@ "kind_ko": "노임", "name_ko": "중소제조업 직종별 임금", "publisher": "중소기업중앙회", - "where_to_get": "", + "where_to_get": "중소기업중앙회 https://www.kbiz.or.kr | [정보마당] > [조사연구통계](게시판 mnSeq=325) > 「중소제조업 직종별 임금조사보고서」 PDF", "cycle": "반기", "latest_published": "", "latest_basis": "", "our_edition": "2026-07-01", "our_edition_from": "effective_date", "status": "unknown", - "checked_on": "2026-09-15", - "note": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다. 우리 판은 2026 상반기 조사(조사월 2026-03 · 공표 2026-06-30)다.", + "checked_on": "2026-09-16", + "note": "우리 판은 2026 상반기 조사(조사월 2026-03 · 공표 2026-06-30)다. ⚠ 게시판에 반기마다 새 보고서가 올라오므로 **최신 공표일은 게시판에서 봐야** 한다.", "files": [ "resources/data_cost_input_value/labor_mfg_2026-07-01.json" ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." }, { @@ -86,19 +86,18 @@ "kind_ko": "기계", "name_ko": "건설공사 표준품셈 제8장 건설기계", "publisher": "국토교통부", - "where_to_get": "", + "where_to_get": "공사비원가관리센터 https://cost.kict.re.kr | [표준품셈] (또는 CODIL https://www.codil.or.kr [건설기준정보] > [건설공사표준품셈]) > 건설공사 표준품셈 원문 PDF(공통부문 제8장 건설기계 8-3)", "cycle": "해마다", "latest_published": "2026-01-01", "latest_basis": "2026-09-15 브레인 쪽지", "our_edition": "2026-01-01", "our_edition_from": "effective_date", "status": "current", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "우리 판이 최신이다. 취득가·손료계수·연료소모량·조종원이 다 여기서 온다.", "files": [ "resources/data_cost_input_value/mach_base_2026.json" - ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + ] }, { "source_id": "machine_operating", @@ -106,19 +105,18 @@ "kind_ko": "기계", "name_ko": "건설기계 운전경비(품셈 8-4 파생본)", "publisher": "국토교통부", - "where_to_get": "", + "where_to_get": "공사비원가관리센터 https://cost.kict.re.kr | 별도 배포처 없음(위 건설공사 표준품셈 원문과 같음) > 공통부문 제8장 건설기계 「8-4 건설기계 운전경비 산정」 표", "cycle": "해마다 — 위 품셈을 따라간다", "latest_published": "2026-01-01", "latest_basis": "파생 원본(mach_base)의 판", "our_edition": "2026-01-01", "our_edition_from": "derived_from.effective_date", "status": "current", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "⚠ **판을 모르는 파일이 아니다** — 파일 안 `derived_from.effective_date` 에 2026-01-01 이 박혀 있다(건설품셈 8-4 운전경비 파생본 · 원본 지문까지 함께). `mach_fuel_rate`·`mach_operator_map` 이 다 차면 걷어낼 자리다.", "files": [ "resources/data_cost_machine_operating/machine_operating_2026.json" - ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + ] }, { "source_id": "mat_price_public", @@ -126,19 +124,18 @@ "kind_ko": "자재", "name_ko": "관급자재 단가(시설공통자재)", "publisher": "조달청", - "where_to_get": "", + "where_to_get": "나라장터 https://www.g2b.go.kr | [가격정보] > [시설공통자재] (또는 조달청 https://www.pps.go.kr [정보제공] > [업무별자료] > [시설공사]) > 시설공통자재 가격조사 엑셀", "cycle": "반기 + 수시", "latest_published": "", "latest_basis": "", "our_edition": "2026-08-14", "our_edition_from": "effective_date", "status": "unknown", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "수시 공고라 「최신 공표일」이 한 날로 안 정해진다 — 받은 날(우리 판)이 곧 기준이다. 철근·레미콘·아스콘은 이 벌에 없다(쇼핑몰 스냅숏 미확보).", "files": [ "resources/data_cost_input_value/mat_price_public_2026-08-14.json" ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." }, { @@ -147,19 +144,18 @@ "kind_ko": "유가", "name_ko": "유가 — 전국평균", "publisher": "한국석유공사(오피넷)", - "where_to_get": "", + "where_to_get": "오피넷 https://www.opinet.co.kr | [국내유가통계] > [주유소] > [평균판매가격] — 「전국」 탭 조회 뒤 [엑셀저장](API avgAllPrice.do)", "cycle": "주간 · 일별", "latest_published": "", "latest_basis": "", "our_edition": "2026-08-14", "our_edition_from": "effective_date", "status": "unknown", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "날마다 바뀌는 값이라 「최신」이 곧 오늘이다. **뒤처짐을 날짜 차이로 보여야 하는 자료**다. ⚠ 시도별 판과 「전국」 줄이 겹치는데 값도 기준일도 다르다(어느 쪽을 쓸지 판정 대기).", "files": [ "resources/data_cost_input_value/oil_2026-08-14.json" ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." }, { @@ -168,19 +164,18 @@ "kind_ko": "유가", "name_ko": "유가 — 시도별", "publisher": "한국석유공사(오피넷)", - "where_to_get": "", + "where_to_get": "오피넷 https://www.opinet.co.kr | [국내유가통계] > [주유소] > [평균판매가격] — 「지역별」 탭에서 시도를 고른 뒤 [엑셀저장](API avgSidoPrice.do)", "cycle": "주간 · 일별", "latest_published": "", "latest_basis": "", "our_edition": "2026-09-09", "our_edition_from": "effective_date", "status": "unknown", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」를 따르려고 둔 벌이다.", "files": [ "resources/data_cost_input_value/oil_regional_2026-09-09.json" ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." }, { @@ -189,19 +184,18 @@ "kind_ko": "요율", "name_ko": "토목공사 원가계산 제비율 적용기준", "publisher": "조달청", - "where_to_get": "", + "where_to_get": "조달청 https://www.pps.go.kr | [정보제공] > [업무별자료] > [시설공사] > 「시설공사 원가계산 간접공사비(제비율) 적용기준」 토목 엑셀(재게시 kseis https://www.kseis.co.kr 자료실)", "cycle": "해마다 + 수시(법이 바뀌면)", "latest_published": "2026-04-13", "latest_basis": "2026-09-15 브레인 쪽지", "our_edition": "2026-04-13", "our_edition_from": "effective_date", "status": "current", - "checked_on": "2026-09-15", + "checked_on": "2026-09-16", "note": "우리 판이 최신이다. 국민연금 요율은 국민연금법(현행 2026-06-17)을 따로 물었다. ⚠ 요율은 **법이 바뀌면 주기와 상관없이** 바뀐다 — 주기만 보고 안심하면 안 된다.", "files": [ "resources/data_cost_input_value/rates_2026.json" - ], - "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + ] } ] } From d9f06913f1ef2084b025e7b379bdf3ead559c937 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 14:06:36 +0900 Subject: [PATCH 38/48] =?UTF-8?q?feat(z01):=20=EB=8F=8C=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=ED=91=9C=EC=A4=80=EA=B2=BD=EC=82=AC=20=ED=91=9C=EC=97=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=95=9C=20=EC=A4=84=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EB=A9=B4=20=ED=9A=A1=EB=8B=A8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EB=AA=A8=EC=96=91=EB=8F=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=B0=94=EB=80=9C(B06=20=EC=9D=B4=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=B2=BD=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EA=B7=B8=EB=A6=BC)=20=C2=B7=20=EC=95=84?= =?UTF-8?q?=ED=99=89=20=EC=A4=91=20=EA=B7=B8=EB=A6=BC=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EB=8B=BF=EB=8A=94=20=ED=95=98=EB=82=98=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EB=8A=94=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EB=83=84(?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=82=AC=EC=A0=84=EC=9D=B4=20=EC=83=9D?= =?UTF-8?q?=EA=B8=B0=EB=A9=B4=20=EB=91=90=20=EB=B2=8C=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 7 ++++++- resources/tester/test_z01_base_tables.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 1157d334..fcd75a28 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -338,9 +338,14 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st return sorted(filled, key=lambda r: _order_key(r[column]), reverse=desc) + empty +#: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인). +_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림" +_EXTRA_NOTICE = {"rate": _RATE_NOTICE, "masonry_slope": _SLOPE_NOTICE} + + def notice(kind: str) -> list[str]: """표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③).""" - return [_SCOPE_NOTICE] + ([_RATE_NOTICE] if kind == "rate" else []) + return [_SCOPE_NOTICE] + ([_EXTRA_NOTICE[kind]] if kind in _EXTRA_NOTICE else []) def spec(kind: str, columns: list[str]) -> dict[str, Any]: diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 429320b9..97671f68 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -75,6 +75,17 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로) +def test_돌쌓기_경사는_도면에도_닿는다고_알림(client: TestClient) -> None: + """아홉 중 하나만 수량 말고 **그림**에도 닿음 — 화면이 사전을 가지면 문구가 두 벌(브레인).""" + slope = _get(client, "masonry_slope", size=1)["notice"] + assert any("횡단 도면" in line for line in slope) and len(slope) == 2 + assert all( + not any("횡단 도면" in line for line in _get(client, kind, size=1)["notice"]) + for kind in NEW_KINDS + if kind != "masonry_slope" + ) + + def test_값표만_태우고_이음표는_안_태움(client: TestClient) -> None: formwork = " ".join(_ids(_get(client, "formwork_reuse", size=500))) assert "type_map" not in formwork and "euroform_type/classes" in formwork From 75a84552f1a0d78639b045174053b5d5a0fafad0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 14:07:14 +0900 Subject: [PATCH 39/48] docs(cost): add manufacturing wage source --- .../technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md b/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md index 31348293..838ed41a 100644 --- a/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md +++ b/resources/knowledge/technical_info/01_임도/05_원가정보/원가_기초데이터_원천.md @@ -138,7 +138,8 @@ last_updated: 2026-09-16 | 요율 | 조달청 토목공사 원가계산 제비율 적용기준 및 개별 법령·고시 | 연간 + 법령·고시 개정 시 수시 | 우리·최신 확인 `2026-04-13` | - 건설 노임 입수처: **대한건설협회 `cak.or.kr` → [지원·사업] → [건설적산기준] → [건설임금]**. 현재 프로그램 자료 [`labor_const_2026-01-01.json`](../../../../data_cost_input_value/labor_const_2026-01-01.json)은 2026년 9월 1일 판을 아직 반영하지 않았다. -- 기계·유가·요율의 판정 기준과 파일별 기준일은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)를 따른다. 제조 노임의 입수 주소와 최신 판, 조달 자재의 단일 최신일은 **근거 미확인**이다. +- 제조 노임 입수처: **중소기업중앙회 `kbiz.or.kr` → [정보마당] → [조사연구통계](게시판 `mnSeq=325`) → 「중소제조업 직종별 임금조사보고서」 PDF**. 최신 판은 **근거 미확인**이다. +- 기계·유가·요율의 판정 기준과 파일별 기준일은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)를 따른다. 조달 자재는 수시 공고라 단일 최신 공표일이 정해지지 않는다. #### 갱신할 때 행 구성이 바뀌는 범위 From 028a6be2470f17e3183718f929d8acf8da773cac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:19:08 +0900 Subject: [PATCH 40/48] =?UTF-8?q?feat(z01=C2=B7b09):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EB=B0=95=ED=98=80=20=EC=9E=88=EB=8D=98=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=9E=91=EC=97=85=EB=9F=89=20=EB=B0=91=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=A7=88=EC=8A=A4=ED=84=B0=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EB=83=84=20=E2=80=94=20=EC=83=88=20=EC=9E=90=EB=A3=8C=20data?= =?UTF-8?q?=5Fmachine=5Fproductivity(=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=2025=20=C2=B7=20=EC=82=BD=EB=82=A0=2010=20?= =?UTF-8?q?=C2=B7=20=EB=8D=A4=ED=94=84=20=EC=9A=B4=EB=B0=98=C2=B7=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EB=B0=91=EA=B0=92=209=20=C2=B7=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=EB=B3=84=203)=20=C2=B7=20B09=20=EB=8A=94=20=EA=B7=B8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=9D=BD=EC=9D=8C(=EA=B0=92?= =?UTF-8?q?=EC=9D=80=20=ED=95=9C=20=EC=B9=B8=EB=8F=84=20=EC=95=88=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=9B=90=EB=AC=B8=20pum=5Fconst?= =?UTF-8?q?=20C0426=C2=B7C0427=20=EB=8C=80=EC=A1=B0=20=EC=8B=9C=ED=97=98)?= =?UTF-8?q?=20=C2=B7=20Z01=20kind=20machine=5Fproductivity=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EA=B0=80=20=EA=B3=A0=EC=B9=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=C2=B7=20=EC=8B=9D=EC=9D=80=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=B4=EB=9D=BC=20=EC=BD=94=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80=20=C2=B7=20=EC=9D=B4=EB=A6=84=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=83=88=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EB=8A=94=20=ED=8F=B4=EB=8D=94=EC=97=90=EC=84=9C=20=EC=B0=BE?= =?UTF-8?q?=EC=9D=8C(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9B=90=EC=B9=99=20?= =?UTF-8?q?=E3=80=8C=EB=A7=88=EC=8A=A4=ED=84=B0=20=3D=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=A0=9C=EC=96=B4=ED=95=A0=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EC=BD=94=EB=93=9C=20=EC=95=88=EC=97=90=20=EC=9E=88?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=95=88=20=EB=90=A8=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- ...B09_Estimation_MachineProductivity_Data.py | 68 +++ ...09_Estimation_MachineProductivity_Dozer.py | 52 +-- ...B09_Estimation_MachineProductivity_Dump.py | 58 +-- Z01_MasterData/Z01_MasterData_BasePrices.py | 10 +- .../Z01_MasterData_BasePrices_Tables.py | 83 ++++ .../machine_productivity_2026-01-01.json | 386 ++++++++++++++++++ resources/tester/test_z01_base_tables.py | 67 +++ 7 files changed, 644 insertions(+), 80 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity_Data.py create mode 100644 resources/data_machine_productivity/machine_productivity_2026-01-01.json diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Data.py b/B09_Estimation/B09_Estimation_MachineProductivity_Data.py new file mode 100644 index 00000000..828f1cb9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Data.py @@ -0,0 +1,68 @@ +"""B09 — 기계 작업량 **밑값**을 마스터에서 읽는다(2026-09-16 사용자 원칙 · 브레인 지시). + + 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨.** + +그동안 불도저 속도·삽날 용량(건설품셈 8-2-1)과 덤프 운반·적재 식 계수(산림품셈 10-12)가 +코드 상수로 박혀 있었다. 값은 그대로 두고 자리만 옮긴다 — +`resources/data_machine_productivity/machine_productivity_<판>.json`. +⚠ **식은 여기 없다.** `Q = n·q·f·E` 같은 식은 값이 아니라 로직이라 계산 모듈에 남는다. +⚠ 옮기면서 값을 하나도 안 바꿨다 — 원문(pum_const C0426·C0427)과의 대조는 시험이 지킨다. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +DATA_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_machine_productivity" +PREFIX = "machine_productivity_" + + +class MachineProductivityDataError(FileNotFoundError): + """밑값 판이 없다 — 지어내지 않고 멈춘다.""" + + +@lru_cache(maxsize=2) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def load() -> dict[str, Any]: + """끝 판 한 벌(이름 차례).""" + found = sorted(DATA_DIR.glob(PREFIX + "*.json")) if DATA_DIR.is_dir() else [] + if not found: + raise MachineProductivityDataError(f"기계 작업량 밑값 판이 없습니다: {DATA_DIR}") + return _read(str(found[-1]), found[-1].stat().st_mtime_ns) + + +def records(variable: str) -> list[dict[str, Any]]: + return load()["variables"][variable]["records"] + + +def dozer_speeds() -> dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]]: + """{무한궤도·타이어: {규격(ton): {단: (전진, 후진)}}} — 후진이 원문에 없는 단은 담기지 않는다.""" + out: dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]] = {} + for row in records("dozer_speed"): + track = out.setdefault(row["track"], {}) + gears = track.setdefault(Decimal(row["tonnage_ton"]), {}) + gears[int(row["gear"])] = ( + Decimal(row["forward_m_per_min"]), + Decimal(row["reverse_m_per_min"]), + ) + return out + + +def dozer_blades() -> dict[tuple[str, Decimal], Decimal]: + """{(무한궤도·타이어, 규격(ton)): 삽날 용량 q˚(㎥)}.""" + return { + (row["track"], Decimal(row["tonnage_ton"])): Decimal(row["blade_m3"]) + for row in records("dozer_blade") + } + + +def haul_params() -> dict[str, Decimal]: + """덤프 운반·적재 식이 쓰는 밑값(적재 규격 T · 버켓 q · 싸이클 · 주행속도 · 적하·대기·덮개).""" + return {row["key"]: Decimal(row["value"]) for row in records("dump_haul")} diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index 6aa9dbd1..e27464e1 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any +from B09_Estimation import B09_Estimation_MachineProductivity_Data as data from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, @@ -38,47 +39,13 @@ _ZERO = Decimal(0) #: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.** +#: ⭐ 값은 마스터 `resources/data_machine_productivity/` 에 있다 — 여기 박지 않는다(사용자 원칙). #: {규격(ton): {단: (전진, 후진)}} #: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은 #: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다. #: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반 #: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다. -_DOZER_SPEEDS = { - "무한궤도": { - Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))}, - Decimal("7"): { - 1: (Decimal(43), Decimal(53)), - 2: (Decimal(67), Decimal(78)), - 3: (Decimal(92), Decimal(107)), - }, - Decimal("10"): { - 1: (Decimal(42), Decimal(50)), - 2: (Decimal(64), Decimal(75)), - 3: (Decimal(88), Decimal(105)), - }, - Decimal("12"): { - 1: (Decimal(40), Decimal(48)), - 2: (Decimal(55), Decimal(70)), - 3: (Decimal(75), Decimal(100)), - }, - Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))}, - Decimal("19"): { - 1: (Decimal(40), Decimal(46)), - 2: (Decimal(55), Decimal(70)), - 3: (Decimal(75), Decimal(98)), - }, - Decimal("32"): { - 1: (Decimal(40), Decimal(43)), - 2: (Decimal(52), Decimal(58)), - 3: (Decimal(70), Decimal(78)), - }, - }, - "타이어": { - Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))}, - Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))}, - Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))}, - }, -} +_DOZER_SPEEDS = data.dozer_speeds() # 마스터에서 읽음 — 값은 그대로(2026-09-16 꺼냄) #: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」 _DOZER_GEAR_SHIFT_MIN = Decimal("0.25") _MINUTES_PER_HOUR = Decimal(60) @@ -144,18 +111,7 @@ def dozer_speeds( #: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다. #: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다. -_DOZER_BLADE_M3 = { - ("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지 - ("무한궤도", Decimal("7")): Decimal("1.1"), - ("무한궤도", Decimal("10")): Decimal("1.5"), - ("무한궤도", Decimal("12")): Decimal("2.0"), - ("무한궤도", Decimal("13")): Decimal("1.5"), # 습지 - ("무한궤도", Decimal("19")): Decimal("3.2"), - ("무한궤도", Decimal("32")): Decimal("5.5"), - ("타이어", Decimal("15")): Decimal("3.1"), - ("타이어", Decimal("28")): Decimal("4.0"), - ("타이어", Decimal("33")): Decimal("5.7"), -} +_DOZER_BLADE_M3 = data.dozer_blades() # 마스터에서 읽음 #: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. _DOZER_WET_TONS = (Decimal("4"), Decimal("13")) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py index 361ed6bc..b41764b0 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -31,19 +31,23 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any +from B09_Estimation import B09_Estimation_MachineProductivity_Data as data from B09_Estimation.B09_Estimation_MachineProductivity import fix2 #: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15. DUMP_TRUCK_CODE = "0602-0150" DUMP_PARENT = "FP-10-12" -_TRUCK_TON = Decimal(15) -_BUCKET_M3 = Decimal("0.7") -_LOADER_CYCLE_SEC = Decimal(20) -_V_LOADED_KMH = Decimal(5) -_V_EMPTY_KMH = Decimal(6) -_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5") -_SIXTY = Decimal(60) +#: ⭐ 식이 쓰는 밑값은 **마스터**에 있다 — `resources/data_machine_productivity/`(2026-09-16 꺼냄). +#: 식(Q·㎝t·n)은 값이 아니라 로직이라 여기 남는다. +_HAUL = data.haul_params() +_TRUCK_TON = _HAUL["truck_ton"] +_BUCKET_M3 = _HAUL["bucket_m3"] +_LOADER_CYCLE_SEC = _HAUL["loader_cycle_sec"] +_V_LOADED_KMH = _HAUL["speed_loaded_kmh"] +_V_EMPTY_KMH = _HAUL["speed_empty_kmh"] +_T3_UNLOAD, _T4_WAIT, _T5_COVER = _HAUL["unload_min"], _HAUL["wait_min"], _HAUL["cover_min"] +_SIXTY = Decimal(60) # 분↔시간 환산 — 값이 아니라 단위 @dataclass(frozen=True) @@ -66,37 +70,33 @@ _COMMON_NOTE = ( _ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" DUMP_MATERIALS: dict[str, DumpMaterial] = { - "FP-10-12-01": DumpMaterial( - "FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"), - Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE), - ), - "FP-10-12-02": DumpMaterial( - "FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"), - Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE), - ), - "FP-10-12-03": DumpMaterial( - "FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"), - Decimal("0.9"), - (_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"), - ), -} # fmt: skip + row["work_item_code"]: DumpMaterial( + row["work_item_code"], + row["label"], + Decimal(row["unit_weight_ton_per_m3"]), + Decimal(row["loose_factor"]), + Decimal(row["bucket_factor"]), + Decimal(row["loader_efficiency"]), + Decimal(row["truck_efficiency"]), + tuple(row.get("notes") or ()), + ) + for row in data.records("dump_material") +} #: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°). #: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤ #: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**. LOADER_CODE = "0201-0070" -_LOADING_CYCLE_SEC = Decimal(22) +_LOADING_CYCLE_SEC = _HAUL["loading_cycle_sec"] _LOADING_E0 = { - "FP-10-12-01": Decimal("0.60"), - "FP-10-12-02": Decimal("0.35"), - "FP-10-12-03": Decimal("0.35"), + row["work_item_code"]: Decimal(row["loading_efficiency"]) + for row in data.records("dump_material") } +#: 적재 E0 사유 — 마스터가 줄에 따로 들고 있음(운반 줄 사유와 섞지 않음). _LOADING_NOTES = { - "FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림", - "FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)", - "FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)", -} # fmt: skip + row["work_item_code"]: row.get("loading_note", "") for row in data.records("dump_material") +} def loading_output(code: str) -> Decimal: diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index fcd75a28..7e4e4e91 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -45,12 +45,16 @@ _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름 def _source(file_id: str) -> Path: + """원본 파일 자리 — 이름표가 가리키는 곳이 먼저, 아직 이름표에 없는 새 자료는 폴더에서 찾음.""" labels = tables.load_labels() entry = next((f for f in labels["files"] if f["file_id"] == file_id), None) path = tables._file_path(entry) if entry else None - if path is None: - raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}") - return path + if path is not None: + return path + found = sorted(tables.RESOURCES.glob(f"data_*/{file_id}*.json")) + if not found: + raise FileNotFoundError(f"원본 자리를 못 찾음: {file_id}") + return found[-1] @lru_cache(maxsize=16) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index da07b770..dca7b6e3 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -281,6 +281,73 @@ def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]: return rows +def _machine_productivity(doc: dict[str, Any], name: str) -> list[_ROW]: + """기계 작업량 밑값 — 코드에 박혀 있던 값을 꺼낸 자리(불도저 속도·삽날 · 덤프 운반·적재 계수). + + ⚠ 식(`Q = n·q·f·E` 따위)은 값이 아니라 로직이라 계산 모듈에 남는다 — 여기는 계수만. + """ + rows = [] + for r in doc["variables"]["dozer_speed"]["records"]: + rows.append( + _row( + f"dozer_speed/{r['track']}/{r['tonnage_ton']}/{r['gear']}", + name, + ("table", "track", "tonnage_ton", "gear"), + table="dozer_speed", + track=r["track"], + tonnage_ton=float(r["tonnage_ton"]), + gear=int(r["gear"]), + forward_m_per_min=float(r["forward_m_per_min"]), + reverse_m_per_min=float(r["reverse_m_per_min"]), + ) + ) + for r in doc["variables"]["dozer_blade"]["records"]: + rows.append( + _row( + f"dozer_blade/{r['track']}/{r['tonnage_ton']}", + name, + ("table", "track", "tonnage_ton"), + table="dozer_blade", + track=r["track"], + tonnage_ton=float(r["tonnage_ton"]), + blade_m3=float(r["blade_m3"]), + ) + ) + for r in doc["variables"]["dump_haul"]["records"]: + rows.append( + _row( + f"dump_haul/{r['key']}", + name, + ("table", "key"), + table="dump_haul", + key=r["key"], + value=float(r["value"]), + unit=r.get("unit"), + meaning=r.get("meaning"), + ) + ) + for r in doc["variables"]["dump_material"]["records"]: + rows.append( + _row( + f"dump_material/{r['work_item_code']}", + name, + ("table", "work_item_code"), + table="dump_material", + work_item_code=r["work_item_code"], + label=r["label"], + unit_weight_ton_per_m3=float(r["unit_weight_ton_per_m3"]), + loose_factor=float(r["loose_factor"]), + bucket_factor=float(r["bucket_factor"]), + loader_efficiency=float(r["loader_efficiency"]), + truck_efficiency=float(r["truck_efficiency"]), + loading_efficiency=float(r["loading_efficiency"]), + notes=r.get("notes"), + loading_note=r.get("loading_note"), + ) + ) + return rows + + #: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인). SPEC: dict[str, dict[str, Any]] = { "coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]}, @@ -315,6 +382,22 @@ SPEC: dict[str, dict[str, Any]] = { "editable": ["min_cm", "max_cm"], }, "stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]}, + "machine_productivity": { + "file": "machine_productivity", + "build": _machine_productivity, + "editable": [ + "forward_m_per_min", + "reverse_m_per_min", + "blade_m3", + "value", + "unit_weight_ton_per_m3", + "loose_factor", + "bucket_factor", + "loader_efficiency", + "truck_efficiency", + "loading_efficiency", + ], + }, "masonry_class": { "file": "masonry_class", "build": _masonry_class, diff --git a/resources/data_machine_productivity/machine_productivity_2026-01-01.json b/resources/data_machine_productivity/machine_productivity_2026-01-01.json new file mode 100644 index 00000000..ff05aac3 --- /dev/null +++ b/resources/data_machine_productivity/machine_productivity_2026-01-01.json @@ -0,0 +1,386 @@ +{ + "schema_version": "1.0", + "dataset_id": "machine_productivity", + "effective_date": "2026-01-01", + "note": "기계 작업량 밑값 — 코드에 박혀 있던 값을 그대로 옮긴 벌(2026-09-16 사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」). 값은 한 칸도 안 바꿨다 — 옮기기 전후 금액이 같다.", + "policy": { + "no_value_changed": "코드에 있던 값을 그대로 옮겼다. 원문과의 대조는 시험이 지킨다.", + "formula_stays_in_code": "식(Q = n·q·f·E 따위)은 값이 아니라 로직이라 코드에 둔다 — 계수만 여기 있다.", + "source_tables_mashed": "삽날 용량표는 원문 옮기기에서 절 이름이 뭉개져(`5-3.2-5.5-타 이 어…`) 표로 안 들어왔다. 값은 원문 본문 것이고, 원문 표가 제 모양으로 들어오면 그때 대조한다." + }, + "sources": [ + { + "doc": "건설공사 표준품셈 8-2-1 불도저 2.가·나 전진·후진 속도", + "tables": [ + "C0426", + "C0427" + ], + "role": "primary" + }, + { + "doc": "건설공사 표준품셈 8-2-1 1.가 삽날 용량 q˚", + "tables": [], + "role": "primary", + "note": "원문 표가 뭉개져 카탈로그에 표로 없음" + }, + { + "doc": "산림사업 표준품셈(고시 제2025-82호) 10-12 「1. 적재」·「2. 운반」 식 서식", + "tables": [], + "role": "primary", + "note": "원문이 표가 아니라 채워 넣는 식이라 공종 마스터에 안 실림" + } + ], + "variables": { + "dozer_speed": { + "unit": "m/분", + "key": "track+tonnage_ton+gear", + "note": "후진이 「-」인 단은 원문에 값이 없어 담지 않는다(없는 속도로 싸이클이 서면 값이 조용히 틀림).", + "records": [ + { + "track": "무한궤도", + "tonnage_ton": "4", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "63" + }, + { + "track": "무한궤도", + "tonnage_ton": "4", + "gear": 2, + "forward_m_per_min": "57", + "reverse_m_per_min": "85" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 1, + "forward_m_per_min": "43", + "reverse_m_per_min": "53" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 2, + "forward_m_per_min": "67", + "reverse_m_per_min": "78" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 3, + "forward_m_per_min": "92", + "reverse_m_per_min": "107" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 1, + "forward_m_per_min": "42", + "reverse_m_per_min": "50" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 2, + "forward_m_per_min": "64", + "reverse_m_per_min": "75" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 3, + "forward_m_per_min": "88", + "reverse_m_per_min": "105" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "48" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 3, + "forward_m_per_min": "75", + "reverse_m_per_min": "100" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "48" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "46" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 3, + "forward_m_per_min": "75", + "reverse_m_per_min": "98" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "43" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 2, + "forward_m_per_min": "52", + "reverse_m_per_min": "58" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 3, + "forward_m_per_min": "70", + "reverse_m_per_min": "78" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "gear": 1, + "forward_m_per_min": "83", + "reverse_m_per_min": "92" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "gear": 2, + "forward_m_per_min": "200", + "reverse_m_per_min": "125" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "gear": 1, + "forward_m_per_min": "92", + "reverse_m_per_min": "92" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "gear": 2, + "forward_m_per_min": "200", + "reverse_m_per_min": "200" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "gear": 1, + "forward_m_per_min": "92", + "reverse_m_per_min": "110" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "gear": 2, + "forward_m_per_min": "210", + "reverse_m_per_min": "250" + } + ] + }, + "dozer_blade": { + "unit": "㎥", + "key": "track+tonnage_ton", + "note": "무한궤도 10·13 톤이 둘 다 1.5 ㎥ 라 용량만으로는 못 가른다 — 속도로 마저 가른다.", + "records": [ + { + "track": "무한궤도", + "tonnage_ton": "4", + "blade_m3": "0.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "blade_m3": "1.1" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "blade_m3": "1.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "blade_m3": "2.0" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "blade_m3": "1.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "blade_m3": "3.2" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "blade_m3": "5.5" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "blade_m3": "3.1" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "blade_m3": "4.0" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "blade_m3": "5.7" + } + ] + }, + "dump_haul": { + "key": "key", + "note": "덤프 운반·적재 식이 쓰는 밑값. 장비 규격이 바뀌면 여기를 고친다.", + "records": [ + { + "key": "truck_ton", + "value": "15", + "unit": "ton", + "meaning": "덤프트럭 적재 규격 T" + }, + { + "key": "bucket_m3", + "value": "0.7", + "unit": "㎥", + "meaning": "적재기계 버켓 용량 q" + }, + { + "key": "loader_cycle_sec", + "value": "20", + "unit": "초", + "meaning": "적재 대기 싸이클 ㎝s" + }, + { + "key": "speed_loaded_kmh", + "value": "5", + "unit": "㎞/hr", + "meaning": "적재 주행속도 V1" + }, + { + "key": "speed_empty_kmh", + "value": "6", + "unit": "㎞/hr", + "meaning": "공차 주행속도 V2" + }, + { + "key": "unload_min", + "value": "1.1", + "unit": "분", + "meaning": "적하 시간 t3" + }, + { + "key": "wait_min", + "value": "0.9", + "unit": "분", + "meaning": "대기 시간 t4" + }, + { + "key": "cover_min", + "value": "0.5", + "unit": "분", + "meaning": "덮개 시간 t5" + }, + { + "key": "loading_cycle_sec", + "value": "22", + "unit": "초", + "meaning": "적재 식 싸이클 ㎝(180°)" + } + ] + }, + "dump_material": { + "key": "work_item_code", + "note": "10-12 절 재료별 값 — 고른 값·버린 값 사유를 줄에 함께 둔다. 적재 사유는 `loading_note` 로 따로 둔다(운반 줄 사유와 섞지 않음).", + "records": [ + { + "work_item_code": "FP-10-12-01", + "label": "토사", + "unit_weight_ton_per_m3": "1.9", + "loose_factor": "1.3", + "bucket_factor": "0.9", + "loader_efficiency": "0.85", + "truck_efficiency": "0.9", + "loading_efficiency": "0.60", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + ], + "loading_note": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림" + }, + { + "work_item_code": "FP-10-12-02", + "label": "암절취", + "unit_weight_ton_per_m3": "2.4", + "loose_factor": "1.35", + "bucket_factor": "0.55", + "loader_efficiency": "0.35", + "truck_efficiency": "0.9", + "loading_efficiency": "0.35", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림) · 원문 10 이면 n 1.18배", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + ], + "loading_note": "E0 = 0.35(본문·표 「파쇄암」 같음)" + }, + { + "work_item_code": "FP-10-12-03", + "label": "발파암", + "unit_weight_ton_per_m3": "2.4", + "loose_factor": "1.625", + "bucket_factor": "0.55", + "loader_efficiency": "0.35", + "truck_efficiency": "0.9", + "loading_efficiency": "0.35", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)", + "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)" + ], + "loading_note": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)" + } + ] + } + } +} diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 97671f68..2ff80938 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -14,6 +14,8 @@ from __future__ import annotations import json from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -31,6 +33,7 @@ NEW_KINDS = ( "masonry_slope", "masonry_back_length", "stone_kind", + "machine_productivity", # 2026-09-16 코드에서 꺼낸 기계 작업량 밑값 "masonry_class", ) @@ -185,3 +188,67 @@ def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None: json={"values": {"reuse_count": 3}}, ) assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3 + + +def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None: + """사용자 원칙 — 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨**(2026-09-16). + + 불도저 속도·삽날(건설품셈 8-2-1)과 덤프 운반·적재 계수(산림품셈 10-12 식 서식)를 꺼낸 자리. + """ + table = _get(client, "machine_productivity", size=500) + rows = {r["@id"]: r for r in table["rows"]} + assert table["total"] == 25 + 10 + 9 + 3 + assert rows["dozer_speed/무한궤도/7/2"]["forward_m_per_min"] == 67 + assert rows["dozer_speed/타이어/33/2"]["reverse_m_per_min"] == 250 + assert rows["dozer_blade/무한궤도/19"]["blade_m3"] == 3.2 + assert rows["dump_haul/truck_ton"]["value"] == 15 + assert rows["dump_material/FP-10-12-01"]["loose_factor"] == 1.3 + assert "forward_m_per_min" in table["editable"] and "track" in table["locked"] + + +def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None: + """옮기며 값이 바뀌면 금액이 조용히 달라짐 — 코드가 읽는 값과 품셈 원문 둘 다에 댐.""" + import json + import re + from decimal import Decimal + + from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import _DOZER_SPEEDS + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import DUMP_MATERIALS + + assert _DOZER_SPEEDS["무한궤도"][Decimal("12")][2] == (Decimal(55), Decimal(70)) + assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9") + from B09_Estimation import B09_Estimation_MachineProductivity_Dump as dump + + assert (dump._TRUCK_TON, dump._BUCKET_M3, dump._LOADER_CYCLE_SEC) == ( + Decimal(15), + Decimal("0.7"), + Decimal(20), + ) + assert (dump._V_LOADED_KMH, dump._V_EMPTY_KMH, dump._LOADING_CYCLE_SEC) == ( + Decimal(5), + Decimal(6), + Decimal(22), + ) + pum = json.loads( + (ROOT / "resources/data_cost_input_value/pum_const_2026.json").read_text(encoding="utf-8") + )["variables"]["pum"]["tables"] + by_id = {t["table_id"]: t for t in pum} + + def source(table_id: str, gears: int) -> dict: + out = {} + for row in by_id[table_id]["rows"][1:]: + ton = re.sub(r"\(.*?\)", "", str(row[0])).strip() + if not ton.replace(".", "").isdigit(): + continue + cells = [str(v).strip() for v in row[1:]] + out[Decimal(ton)] = { + i + 1: (f, r) + for i, (f, r) in enumerate(zip(cells[:gears], cells[gears : gears * 2])) + } + return out + + for track, table_id, gears in (("무한궤도", "C0426", 4), ("타이어", "C0427", 3)): + original = source(table_id, gears) + for ton, speeds in _DOZER_SPEEDS[track].items(): + for gear, (forward, reverse) in speeds.items(): + assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear) From b6fb3725438fd67f696644ac34de5010384081e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:19:09 +0900 Subject: [PATCH 41/48] auto: 2026-09-16 15:19 (ESD_LAPTOP) --- .../B09_Estimation_MachineProductivity_Reference.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 311bb893..51fdb5d6 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -26,7 +26,6 @@ from __future__ import annotations import re - from decimal import Decimal from typing import Any @@ -235,9 +234,8 @@ _CAPACITY_UNIT = re.compile(r"[((]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시 def _paired_machine(node: dict[str, Any]) -> tuple[str, str, str] | None: """그 표에 함께 나오는 기종 — (코드, 이름, 규격). 「유압식백호우 (…0.7㎥)」 → 0201-0070.""" - from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine - from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine catalog = load_machine_catalog() for table in node.get("tables", []): From 3c4b915f7e4db3468602c9fd7fca4080054d1fb4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:41:58 +0900 Subject: [PATCH 42/48] =?UTF-8?q?fix(b09):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=88=98=EC=86=A1=EB=B9=84=EA=B0=80=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=9E=88=200=20=EC=9C=BC=EB=A1=9C=20=EB=B9=A0=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EC=9E=90=EB=A6=AC=20=E2=80=94=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?(=EC=9D=B8=EA=B7=BC=20=EC=8B=9C=C2=B7=EA=B5=B0=C2=B7=EA=B5=AC?= =?UTF-8?q?=EC=B2=AD=20=E2=86=92=20=ED=98=84=EC=9E=A5)=EA=B0=80=20?= =?UTF-8?q?=EB=B9=84=EC=96=B4=20=EC=A4=91=EA=B8=B0=EC=9A=B4=EB=B0=98(10-4)?= =?UTF-8?q?=20=EC=A4=84=EC=9D=B4=20=EC=95=88=20=EC=84=A4=20=EB=95=8C=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EB=A5=BC=20=EA=B7=B8=20=EA=B3=B5=EC=A2=85?= =?UTF-8?q?=EC=97=90=20=EB=B6=99=EC=9E=84(=EC=95=84=EB=9E=98=20=EC=B8=B5?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=EC=9D=80=20=EC=95=88=20=EC=A7=80=EC=96=B4?= =?UTF-8?q?=EB=83=84)=20=C2=B7=20=EC=A4=84=EC=9D=B4=20=EC=84=9C=EB=A9=B4?= =?UTF-8?q?=20=EC=82=AC=EC=9C=A0=20=EC=95=88=20=EB=B6=99=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=A1=B0=EC=82=AC=EC=97=90=EC=84=9C=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EA=B0=80=20=EC=88=98?= =?UTF-8?q?=EC=86=A1=EB=B9=84=200=20=EC=9D=B4=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EB=8F=84=20=EC=97=86=EB=8D=98=20=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_UnitPrice.py | 8 ++++ resources/tester/test_b09_transport_reason.py | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 resources/tester/test_b09_transport_reason.py diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 5005265c..e025561f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1100,6 +1100,7 @@ def build_unit_prices( attach_work_items_ax(build) # 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다. + from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE from B09_Estimation.B09_Estimation_Transport import attach_transport build.transport_notes = attach_transport( @@ -1108,6 +1109,13 @@ def build_unit_prices( distance_km=transport_distance_km, road_key=transport_road, ) + # ⚠ 줄이 하나도 안 서면 **사유를 그 공종에 붙인다** — 안 그러면 수송비가 조용히 0 으로 빠진다 + # (2026-09-16 조사: 거리가 비어 모든 프로젝트에서 안 서고 있었고 사유도 안 떴다). + # 문구는 아래 층 것 **그대로** 쓴다 — 여기서 새로 짓지 않는다(까닭은 그대로 올림). + if build.transport_notes and not any( + code.startswith(f"B-{TRANSPORT_CODE}") for code in build.book.titles + ): + build.component_gaps[TRANSPORT_CODE] = build.transport_notes[0] # 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」). attach_dump_hauls(build, master, dump_haul_m) diff --git a/resources/tester/test_b09_transport_reason.py b/resources/tester/test_b09_transport_reason.py new file mode 100644 index 00000000..24c47929 --- /dev/null +++ b/resources/tester/test_b09_transport_reason.py @@ -0,0 +1,39 @@ +"""B09 기계 수송비 — 거리가 비면 **사유가 붙은 빈 줄**이 선다(2026-09-16 브레인 · 사용자 지시). + +기계경비는 손료 + 운전경비 + **수송비**(건설품셈 8-1-6의 1)인데, 수송비는 거리(인근 시·군·구청 +소재지 → 현장)가 설계 입력이라 안 넣으면 줄이 안 섰다. ⚠ 그때 **사유도 안 떠서** 모든 프로젝트에서 +수송비가 조용히 0 이었다(2026-09-16 조사) — 「조용히 빠짐」은 우리가 내내 막아 온 자리다. +⇒ 금액은 지어내지 않는다. **사유만** 세운다 — 문구는 아래 층(`B09_Estimation_Transport`) 것 그대로. +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def test_거리가_비면_사유가_서고_금액은_안_섬() -> None: + build = cached_build() + assert f"B-{WORK_ITEM_CODE}" not in build.book.titles # 금액을 지어내지 않음 + gap = build.component_gaps.get(WORK_ITEM_CODE) or "" + assert "거리" in gap and "산출 조건" in gap + assert gap in build.transport_notes # 아래 층 문구 그대로 — 새로 짓지 않음 + + +def test_거리를_넣으면_줄이_서고_사유는_사라짐() -> None: + build = cached_build(transport_distance_km="12", transport_road="paved") + codes = [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")] + assert codes, "거리를 넣었는데 수송비 줄이 안 섬" + assert WORK_ITEM_CODE not in build.component_gaps + detail = build.book.details[codes[0]][0] + assert detail.quantity > Decimal(0) + + +def test_한_갈래만_서면_사유는_그_갈래에만_남음() -> None: + """고속4차선은 원문에 트레일러 속도가 「-」 — 덤프 줄은 서고 트레일러만 사유.""" + build = cached_build(transport_distance_km="12", transport_road="expressway_4") + assert [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")] + assert build.transport_notes and any("원문에 없습니다" in n for n in build.transport_notes) + assert WORK_ITEM_CODE not in build.component_gaps # 줄이 섰으면 공종 사유는 안 붙음 From 389d0140186506a9ff178f59b47e259e72ce0d23 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:48:20 +0900 Subject: [PATCH 43/48] =?UTF-8?q?fix(z01):=20=EC=BD=94=EB=8D=B1=EC=8A=A4?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=91=98=20=E2=80=94=20=EC=B2=A0?= =?UTF-8?q?=EA=B7=BC=20=EB=8B=A8=EA=B0=80=20=EC=B0=B8=EA=B3=A0=EA=B0=92?= =?UTF-8?q?=EC=9D=80=20B09=20=EA=B3=84=EC=82=B0=EA=B0=92=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=A0=20=EC=B9=B8=EC=97=90=EC=84=9C=20=EB=B9=BC?= =?UTF-8?q?=EA=B3=A0=20=E3=80=8C=EA=B3=84=EC=82=B0=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=A0=84=EC=9A=A9=E3=80=8D=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=C2=B7=EC=8B=9D=EC=9D=84=20=EC=8B=A4=EC=9D=8C(?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=A3=EB=8C=80=20=E3=80=8C?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=3D=20=EA=B3=84=EC=82=B0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=EC=98=A4=EB=8A=94=20=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=98=E3=80=8D)=20=C2=B7=20=EB=8F=8C=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=88=EB=9E=98=20=ED=91=9C=EC=97=90=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=201:0.3=20=EC=9D=B4=20=EB=91=90=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=9D=B8=20=EA=B9=8C=EB=8B=AD=20=EC=95=8C=EB=A6=BC(=ED=92=88?= =?UTF-8?q?=EC=85=88=2013-4-4=20[=EC=A3=BC]=E2=91=AA=20=EC=A7=81=EA=B3=A0?= =?UTF-8?q?=C2=B7=EC=84=B1=EC=A0=88=ED=86=A0=20=EC=B6=95=20=E2=86=94=20?= =?UTF-8?q?=EA=B5=90=EB=B3=B8=207-3=20=ED=98=95=EC=8B=9D=EB=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20=EC=B6=95=EA=B3=BC=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=EA=B0=80=20=EB=8B=A4=EB=A6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 25 ++++++++++++++++--- .../Z01_MasterData_BasePrices_Tables.py | 10 +++++++- resources/tester/test_z01_base_tables.py | 25 ++++++++++++++++--- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 7e4e4e91..6de5c298 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -344,7 +344,18 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st #: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인). _SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림" -_EXTRA_NOTICE = {"rate": _RATE_NOTICE, "masonry_slope": _SLOPE_NOTICE} +#: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**. +#: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표) +#: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값) +_CLASS_NOTICE = ( + "전면 기울기 기본값은 **교본 7-3 형식별 값** — 품셈 표준경사(직고·성토/절토 축)는 「돌쌓기 표준경사」 표에 따로 있음" + " · 같은 1:0.3 이라도 **축과 근거가 다름**(판정이 안 되는 자리에서만 이 값으로 섬)" +) +_EXTRA_NOTICE = { + "rate": _RATE_NOTICE, + "masonry_slope": _SLOPE_NOTICE, + "masonry_class": _CLASS_NOTICE, +} def notice(kind: str) -> list[str]: @@ -357,12 +368,18 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]: if kind == "machine": return machine.spec(columns) if kind in base_tables.SPEC: - editable = [c for c in base_tables.SPEC[kind]["editable"] if c in columns] + spec_of = base_tables.SPEC[kind] + editable = [c for c in spec_of["editable"] if c in columns] axis = {a for r in base_rows(kind) for a in r.get("@axis", ())} + told = spec_of.get("locked") or {} # 계산값처럼 까닭이 따로인 칸 return { "editable": editable, - "locked": {c: _KEY if c in axis else _SOURCE for c in columns if c not in editable}, - "formula": {}, + "locked": { + c: told.get(c) or (_KEY if c in axis else _SOURCE) + for c in columns + if c not in editable + }, + "formula": {k: v for k, v in (spec_of.get("formula") or {}).items() if k in columns}, } editable = { "labor": ["daily_wage_krw"], diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index dca7b6e3..05640fa0 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -362,9 +362,17 @@ SPEC: dict[str, dict[str, Any]] = { "editable": ["reuse_count", "ratio_pct", "daily_area_m2"], }, "rebar_complexity": { + # ⚠ 단가 참고값은 **B09 가 자재 단가로 셈한 값**이라 고칠 칸이 아님(코덱스 검증 1 · 2026-09-16). + # 사용자 잣대 「마스터 = 계산으로 나오는 값이 아니라 값 그 자체」와 어긋나 editable 에서 뺌. "file": "rebar_complexity", "build": _rebar_complexity, - "editable": ["price_krw_per_ton"], + "editable": [], + "locked": { + "price_krw_per_ton": "계산값 — B09 가 자재 단가로 셈(표시 전용 · 밑값을 고쳐야 바뀜)" + }, + "formula": { + "price_krw_per_ton": "갈래별 이형철근 단가 참고값 = B09 가 자재 단가에서 셈 — 표시 전용(B08 계산에 안 듦)" + }, }, "timber_structure_class": { "file": "timber_structure_class", diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 2ff80938..19fa7877 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -66,9 +66,8 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None assert table["total"] == len(table["rows"]) > 0, kind ids = _ids(table) assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음 - assert table["editable"] and set(table["editable"]) <= { - c["key"] for c in table["columns"] - }, kind + assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind + assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글 assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())} assert {table["locked"][c] for c in axis if c in table["locked"]} == { @@ -252,3 +251,23 @@ def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None: for ton, speeds in _DOZER_SPEEDS[track].items(): for gear, (forward, reverse) in speeds.items(): assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear) + + +def test_계산값은_고칠_칸이_아니고_까닭이_보임(client: TestClient) -> None: + """코덱스 검증 1 — 철근 단가 참고값은 **B09 계산값**인데 고칠 수 있는 마스터 값으로 앉아 있었음.""" + rebar = _get(client, "rebar_complexity", size=10) + assert "price_krw_per_ton" not in rebar["editable"] + assert "계산값" in rebar["locked"]["price_krw_per_ton"] + assert "표시 전용" in rebar["formula"]["price_krw_per_ton"] + machine = _get(client, "machine", size=1) # 기계 계산 넷도 같은 꼴(잠김 + 식) + for column in ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw"): + assert "계산값" in machine["locked"][column] and column in machine["formula"] + + +def test_같은_기울기가_두_자리인_까닭을_표가_말함(client: TestClient) -> None: + """코덱스 검증 2 — 값은 같은 1:0.3 이나 **축과 근거가 다름**(품셈 직고·성절토 ↔ 교본 형식별).""" + notice = _get(client, "masonry_class", size=1)["notice"] + assert any("교본 7-3" in line and "돌쌓기 표준경사" in line for line in notice) + assert any("축과 근거가 다름" in line for line in notice) + slope = _get(client, "masonry_slope", size=1)["notice"] + assert any("횡단 도면" in line for line in slope) # 표준경사 쪽 알림은 그대로 From 46fe2bc0100f80276817f81aa7a49f3cbaf1bed5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:50:02 +0900 Subject: [PATCH 44/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B3=B5=EB=8A=A5=EB=A0=A5=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=E2=80=94=20=EC=83=88=20kind=20=EC=97=B4=2021?= =?UTF-8?q?=EC=B9=B8(=EC=98=81=EB=AC=B8=2015=EC=B9=B8=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드에 박혀 있던 표를 꺼낸 벌(메인 b6fb3725) — 불도저 속도·삽날 용량(건설품셈 8-2-1) · 덤프 운반 낱값·재료별 계수(산림품셈 10-12) 47 줄 - 영문으로 뜨던 15칸에 이름 — 주행 장치·기계 규격·변속 단·전진/후진 속도·삽날 용량 · 낱값 키·무엇인가 · 단위중량·체적변화율·버킷계수·적재/운반/적재 작업효율·적재 비고 - ⚠ `loader_efficiency`(E0)는 운반 식의 `truck_efficiency`(Es)와 **다른 기호**임을 이름에 적음 — 섞으면 작업량이 갈림 - 줄 열쇠 — id 칸이 없고 **표마다 축이 다름**(속도표 넷 · 삽날표 셋 · 낱값 둘 · 재료별 둘). 서버가 줄마다 `@axis` 로 알려 주는 대로 적음 - 시험 상수에 kind 하나 더함(열넷 → 열다섯) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 167 +++++++++++++++++- resources/tester/test_master_labels_cover.py | 2 + 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index c91f964b..bf0f046d 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-16T13:11:45+09:00", + "generated_at": "2026-09-16T15:44:59+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -1197,6 +1197,153 @@ "note": "형식마다 정해진 값. 코드 기본 0.3 이 여기서 왔다." } ] + }, + { + "key": "machine_productivity", + "name_ko": "기계 시공능력", + "summary": "불도저 속도·삽날 용량과 덤프 운반 계수. 코드에 박혀 있던 표를 꺼낸 벌이다.", + "sources": [ + "B09_Estimation_MachineProductivity_Dozer.py (건설품셈 8-2-1)", + "B09_Estimation_MachineProductivity_Dump.py (산림품셈 10-12)" + ], + "rows_note": "불도저 속도 · 삽날 용량 · 덤프 운반 낱값 · 재료별 계수 = 47 줄", + "columns": [ + { + "key": "table", + "name_ko": "어느 표", + "unit": "", + "visible": true, + "note": "`dozer_speed`(불도저 속도) · `dozer_blade`(삽날 용량) · `dump_haul`(덤프 운반 낱값) · `dump_material`(재료별 계수). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "track", + "name_ko": "주행 장치", + "unit": "", + "visible": true, + "note": "무한궤도 · 타이어. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "tonnage_ton", + "name_ko": "기계 규격", + "unit": "ton", + "visible": true, + "note": "⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "gear", + "name_ko": "변속 단", + "unit": "", + "visible": true, + "note": "1단·2단… 속도가 단마다 다르다. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "forward_m_per_min", + "name_ko": "전진 속도", + "unit": "m/분", + "visible": true + }, + { + "key": "reverse_m_per_min", + "name_ko": "후진 속도", + "unit": "m/분", + "visible": true + }, + { + "key": "blade_m3", + "name_ko": "삽날 용량", + "unit": "㎥", + "visible": true, + "note": "1회에 밀 수 있는 흐트러진 상태 부피." + }, + { + "key": "key", + "name_ko": "낱값 키", + "unit": "", + "visible": true, + "note": "`truck_ton` 처럼 한 값짜리를 가리키는 이름. ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "value", + "name_ko": "값", + "unit": "", + "visible": true, + "note": "낱값 표에서만 선다 — 단위는 곁 칸이 말한다." + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true, + "note": "위 낱값의 단위." + }, + { + "key": "meaning", + "name_ko": "무엇인가", + "unit": "", + "visible": true, + "note": "낱값이 품셈 식에서 어느 기호인지 풀어 적은 자리." + }, + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true, + "note": "덤프 운반 재료 갈래(토사·암절취·발파암). ⚠ 줄을 가리는 축 — 못 고침. 바꾸면 고친 값이 주인을 잃는다." + }, + { + "key": "label", + "name_ko": "재료 이름", + "unit": "", + "visible": true + }, + { + "key": "unit_weight_ton_per_m3", + "name_ko": "단위중량", + "unit": "ton/㎥", + "visible": true, + "note": "자연 상태 토석의 습윤밀도 γt — 적재량을 무게로 묶는 값." + }, + { + "key": "loose_factor", + "name_ko": "체적변화율", + "unit": "L", + "visible": true, + "note": "흐트러진 상태 ÷ 자연 상태. 토량환산계수 표와 같은 축의 값이다." + }, + { + "key": "bucket_factor", + "name_ko": "버킷계수", + "unit": "K", + "visible": true, + "note": "적재 장비가 한 번에 뜨는 몫." + }, + { + "key": "loader_efficiency", + "name_ko": "적재 작업효율", + "unit": "E0", + "visible": true, + "note": "⚠ 운반 식의 `Es` 와 **다른 기호**다 — 섞으면 작업량이 갈린다." + }, + { + "key": "truck_efficiency", + "name_ko": "운반 작업효율", + "unit": "Es", + "visible": true + }, + { + "key": "loading_efficiency", + "name_ko": "적재 효율", + "unit": "E", + "visible": true + }, + { + "key": "loading_note", + "name_ko": "적재 비고", + "unit": "", + "visible": true, + "note": "원문이 값을 안 준 자리와 우리가 고른 까닭." + } + ] } ], "machine_cost_chain": { @@ -1517,6 +1664,20 @@ "type_id" ], "checked": "id 칸이 없다. 뒷길이·돌 직경 갈래는 `table+class`, 형식별 기울기는 `table+type_id`. ⚠ 돌 직경 갈래 이름은 원문 표기 `40~60` 꼴 그대로다." + }, + { + "table": "machine_productivity", + "name_ko": "기계 시공능력", + "verdict": "없음 — 축으로만 가려짐", + "key": [ + "table", + "track", + "tonnage_ton", + "gear", + "key", + "work_item_code" + ], + "checked": "id 칸이 없다. **표마다 축이 다르다** — 속도표는 `table+track+tonnage_ton+gear`, 삽날표는 `table+track+tonnage_ton`, 낱값은 `table+key`, 재료별 계수는 `table+work_item_code`. 서버가 줄마다 `@axis` 로 무엇이 축인지 알려 준다." } ], "null_is_real": { @@ -1610,8 +1771,8 @@ } }, "counts": { - "merged_tables": 14, - "merged_columns": 136, + "merged_tables": 15, + "merged_columns": 156, "files": 34, "tables": 91, "columns": 462, diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 9a9a9c4a..dc363718 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -452,6 +452,7 @@ def test_미결은_미판정으로_표시된다(labels): #: 2026-09-16 사용자 판정 — 기초데이터 아홉이 더해져 열넷. +#: 곧이어 코드에 박혀 있던 기계 시공능력을 꺼내 열다섯(브레인 b6fb3725). #: 잣대 = 계산되는 값이 아니라 **그냥 값만으로 정의되는 데이터셋**. MERGED_KEYS = ( "labor", @@ -468,6 +469,7 @@ MERGED_KEYS = ( "masonry_back_length", "stone_kind", "masonry_class", + "machine_productivity", ) From 8a563f51482792a23dc3226998d59ae0fc202472 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:51:24 +0900 Subject: [PATCH 45/48] =?UTF-8?q?feat(z01):=20=ED=92=88=EC=85=88=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=97=90=20=E3=80=8C=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EB=9F=89=E3=80=8D(machine=5Fproductivity)=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EB=8D=94=ED=95=A8=20=E2=80=94=20=EC=95=84?= =?UTF-8?q?=ED=99=89=20=E2=86=92=20=EC=97=B4(=EB=B6=88=EB=8F=84=EC=A0=80?= =?UTF-8?q?=20=EC=86=8D=EB=8F=84=C2=B7=EC=82=BD=EB=82=A0=C2=B7=EB=8D=A4?= =?UTF-8?q?=ED=94=84=20=EC=9A=B4=EB=B0=98=20=EB=B0=91=EA=B0=92=2047?= =?UTF-8?q?=EC=A4=84)=20=C2=B7=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9D=B4=EB=9D=BC=20=EC=97=B4=2015=20?= =?UTF-8?q?=EB=8A=94=20=EC=98=81=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EB=9C=B8(?= =?UTF-8?q?=EC=A0=95=EC=83=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 1 + ui_template/ui_template_locale_b3.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 7a76b9e7..ddfd40ae 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -63,6 +63,7 @@ export const SPEC_KINDS = [ "masonry_slope", "masonry_back_length", "stone_kind", + "machine_productivity", "masonry_class", ] as const; diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 866bf245..810d777a 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -157,6 +157,7 @@ export const ui_locales_b3 = { Z01_MasterData_Base_masonry_slope: ["돌쌓기 경사", "Masonry slope"], Z01_MasterData_Base_masonry_back_length: ["뒷길이", "Back length"], Z01_MasterData_Base_stone_kind: ["돌종류", "Stone kind"], + Z01_MasterData_Base_machine_productivity: ["기계 작업량", "Machine productivity"], Z01_MasterData_Base_masonry_class: ["갈래", "Masonry class"], Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ From 28a5bcb636f476b0f0e590af4d0be8d4b58c5f0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:56:24 +0900 Subject: [PATCH 46/48] =?UTF-8?q?feat(z01):=20=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EB=83=84=20?= =?UTF-8?q?=E2=80=94=20GET=20/api/master-data/base-prices=20=EA=B0=80=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98(base=5Fprice=C2=B7pumsem=5Fbasis)=20=C2=B7?= =?UTF-8?q?=20kind=20=C2=B7=20=ED=95=9C=EA=B8=80=20=EC=9D=B4=EB=A6=84(?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=ED=91=9C=EC=97=90=EC=84=9C)=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=EB=A5=BC=20=EC=A4=8C=20=C2=B7=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=B4=20kind=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=20=EC=BD=94=EB=93=9C=EC=97=90=20=EB=93=A4=EB=A9=B4=20?= =?UTF-8?q?=EC=83=88=20kind=20=EA=B0=80=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20?= =?UTF-8?q?=EC=95=88=20=EB=9C=B8(=EA=B8=B0=EA=B3=84=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=9F=89=EC=9D=B4=20=EA=B7=B8=EB=9E=AC=EC=9D=8C)=20=C2=B7=20ki?= =?UTF-8?q?nd=20=EB=A5=BC=20=ED=95=98=EB=82=98=20=EB=8D=94=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EC=A0=80=EC=A0=88?= =?UTF-8?q?=EB=A1=9C=20=EB=8A=90=EB=8A=94=EC=A7=80=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=BB=EB=B0=95=EC=9D=8C(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 31 +++++++++++++++++++++ Z01_MasterData/Z01_MasterData_Router.py | 9 +++++- resources/tester/test_z01_base_tables.py | 31 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 6de5c298..174c5315 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -403,6 +403,37 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]: } +#: 갈래 둘 — 기초단가(원가 = 얼마인가) · 품셈 기준(수량 = 얼마나 드나). 화면은 이것으로 상자를 세움. +BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis" + + +def kind_group(kind: str) -> str: + return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP + + +def kind_label(kind: str) -> str: + """kind 한글 이름 — 이름표 `merged_tables` 에서만(코드에 박지 않음 · 없으면 영문 그대로).""" + merged = tables.load_labels().get("merged_tables") or [] + named = next((t for t in merged if t.get("key") in (kind, f"{kind}s")), {}) + return named.get("name_ko") or kind + + +def kinds() -> list[dict[str, Any]]: + """화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸). + + 갈래 · kind · 한글 이름(이름표) · 줄 수. + """ + return [ + { + "group": kind_group(kind), + "kind": kind, + "label": kind_label(kind), + "rows": len(rows(kind)), + } + for kind in KINDS + ] + + def row_label(kind: str, row: dict[str, Any] | None) -> str: if row is None: return "" diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index c9296fe9..7179fef1 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -2,7 +2,8 @@ GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) - GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 기초단가 한 표(labor|machine|material|oil|rate) + GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수) + GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열) PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. @@ -48,6 +49,12 @@ def _kind(kind: str) -> str: return kind +@router.get("/base-prices") +def get_base_price_kinds() -> dict: + """어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄.""" + return {"kinds": base_prices.kinds()} + + @router.get("/base-prices/{kind}") def get_base_prices( kind: str, diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 19fa7877..d65819fa 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -271,3 +271,34 @@ def test_같은_기울기가_두_자리인_까닭을_표가_말함(client: TestC assert any("축과 근거가 다름" in line for line in notice) slope = _get(client, "masonry_slope", size=1)["notice"] assert any("횡단 도면" in line for line in slope) # 표준경사 쪽 알림은 그대로 + + +def test_kind_목록을_서버가_줌_새_kind_도_저절로( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """화면이 kind 목록을 제 코드에 들면 **새 kind 가 조용히 안 뜸**(기계 작업량이 그랬음 · 브레인). + + ⇒ 갈래·kind·한글 이름·줄 수를 서버가 냄. 화면은 상자만 세운다. + """ + listed = client.get("/api/master-data/base-prices") + assert listed.status_code == 200, listed.text + items = listed.json()["kinds"] + assert [i["kind"] for i in items] == list(base_prices.KINDS) + groups = {i["kind"]: i["group"] for i in items} + assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis" + assert {i["group"] for i in items} == {"base_price", "pumsem_basis"} + labor = next(i for i in items if i["kind"] == "labor") + assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서 + assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47 + + from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables + + fake = dict(base_tables.SPEC) + fake["coef_fake"] = dict(base_tables.SPEC["coef"]) + monkeypatch.setattr(base_tables, "SPEC", fake) + monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "coef_fake")) + grown = client.get("/api/master-data/base-prices").json()["kinds"] + assert [i["kind"] for i in grown][-1] == "coef_fake" # 더하면 목록이 저절로 늚 + assert ( + next(i for i in grown if i["kind"] == "coef_fake")["label"] == "coef_fake" + ) # 이름표 전엔 영문 From 9698c7b14a0a6b3b136c90fa973d8b8c0c7d87c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:59:14 +0900 Subject: [PATCH 47/48] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=9E=90=20=EC=9D=B4=EB=A6=84=20=EB=91=98?= =?UTF-8?q?=20=E2=80=94=20base=5Fprice=20=E3=80=8C=EA=B8=B0=EC=B4=88?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=E3=80=8D=20=C2=B7=20pumsem=5Fbasis=20?= =?UTF-8?q?=E3=80=8C=ED=92=88=EC=85=88=20=EA=B8=B0=EC=A4=80=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 `kind_group()` 이 내는 갈래 둘에 한글 이름을 붙임. 상자 설명도 한 줄씩 — 기초단가는 「**얼마인가** · 때가 되면 갈아 끼우는 값」, 품셈 기준은 「**얼마나 드나** · 품셈·법령이 정한 계수」 - ⭐ **서버가 읽을 자리를 못박음** — `labels["base_price_groups"][group]["name_ko"]`. 지금 서버에 갈래 이름 읽는 코드가 아직 없어, 두 창이 서로 다른 자리를 보면 2026-09-15 의 「26칸이 영문으로 뜸」 사고가 되풀이됨 - 시험 둘 더함 — 서버가 내는 갈래마다 이름표에 이름이 있는지 · 상자마다 kind 가 서고 그 kind 이름이 영문 그대로가 아닌지(빈 상자·영문 상자 막음) - 새 kind 열 15칸은 앞 커밋(`46fe2bc0`)에서 이미 이름 붙음 — 지금 영문으로 뜨는 열 0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 12 ++++++++- .../tester/test_master_labels_base_prices.py | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index bf0f046d..4014b7ed 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-16T15:44:59+09:00", + "generated_at": "2026-09-16T15:58:32+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -54,6 +54,16 @@ "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." } }, + "base_price_groups": { + "base_price": { + "name_ko": "기초단가", + "summary": "**얼마인가** — 때가 되면 갈아 끼우는 값(노임·기계·자재·유가·제비율)." + }, + "pumsem_basis": { + "name_ko": "품셈 기준", + "summary": "**얼마나 드나** — 품셈·법령이 정해 둔 계수와 갈래(토량환산·할증·거푸집·철근·돌쌓기 따위)." + } + }, "merged_tables": [ { "key": "labor", diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index 92a36288..67ec9b59 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -209,3 +209,30 @@ def test_축_칸은_못_고친다(): if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") assert not bad, bad + + +def test_기초단가_상자_이름이_이름표에_있다(labels): + """⭐ 서버가 내는 갈래(`group`)마다 한글 이름이 있어야 화면이 영문으로 안 뜬다. + + 서버가 읽을 자리는 `labels["base_price_groups"][group]["name_ko"]` 다 — + 두 창이 서로 다른 자리를 보면 2026-09-15 의 26칸 사고가 되풀이된다. + """ + named = labels["base_price_groups"] + groups = {base_prices.kind_group(kind) for kind in base_prices.KINDS} + missing = groups - set(named) + assert not missing, f"이름표에 없는 갈래: {sorted(missing)}" + for key, entry in named.items(): + assert entry["name_ko"].strip(), key + assert entry["summary"].strip(), key + assert named["base_price"]["name_ko"] == "기초단가" + assert named["pumsem_basis"]["name_ko"] == "품셈 기준" + + +def test_상자마다_kind_가_있다(): + """빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다.""" + counted: dict[str, int] = {} + for entry in base_prices.kinds(): + counted[entry["group"]] = counted.get(entry["group"], 0) + 1 + assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다" + assert entry["rows"] > 0, entry["kind"] + assert all(n > 0 for n in counted.values()), counted From f0d25621c311b7477f0ceaf968becbe74105d946 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 16:01:42 +0900 Subject: [PATCH 48/48] =?UTF-8?q?feat(z01):=20=ED=99=94=EB=A9=B4=EC=9D=B4?= =?UTF-8?q?=20=EB=93=A4=EA=B3=A0=20=EC=9E=88=EB=8D=98=20kind=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=9D=B4=EB=A6=84=20=EC=97=B4=EB=8B=A4=EC=84=AF?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9B=80=20=E2=80=94=20GET=20/base-prices?= =?UTF-8?q?=20=EA=B0=80=20=EC=A4=80=20=EA=B0=88=EB=9E=98=C2=B7kind=C2=B7?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=C2=B7=EC=A4=84=20=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EB=A5=BC=20=EC=84=B8=EC=9B=80(=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=EB=8F=84=20=EC=84=9C=EB=B2=84=20=EA=B2=83)=20=C2=B7?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EC=98=86=20=EC=A4=84=20=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EC=9D=B4=EB=A6=84=EB=8F=84=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?label=20=C2=B7=20kind=20=EA=B0=80=20=EB=8A=98=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=80=EC=A0=88=EB=A1=9C=20=EC=84=9C=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=ED=95=98=EB=82=98(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 32 +++++------ Z01_MasterData/Z01_MasterData_UI_Cells.ts | 19 +++++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 12 ++-- Z01_MasterData/Z01_MasterData_UI_Side.ts | 66 +++++++++++++--------- resources/tester/test_z01_master_cells.py | 20 ++++++- ui_template/ui_template_locale_b3.ts | 20 +------ 6 files changed, 98 insertions(+), 71 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index ddfd40ae..2c93abcc 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,13 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn, OverrideState, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; +import type { + MasterColumn, + OverrideState, + ServedKind, + SortOrder, + TableMeta, +} from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -48,26 +54,14 @@ export interface RowsQuery { } /** - * ① 기초단가 다섯 — 얼마인가. 시장이 정하고 주간~반기로 바뀜. - * 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). + * 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움 + * (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리). */ -export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; +export type BasePriceKind = string; -/** ② 품셈 기준 아홉 — 얼마나 드나. 품셈이 정하고 해마다 한 번 개정(2026-09-16 사용자 ①②). */ -export const SPEC_KINDS = [ - "coef", - "material_surcharge", - "formwork_reuse", - "rebar_complexity", - "timber_structure_class", - "masonry_slope", - "masonry_back_length", - "stone_kind", - "machine_productivity", - "masonry_class", -] as const; - -export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number] | (typeof SPEC_KINDS)[number]; +export function fetchBaseKinds(): Promise { + return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds); +} async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index b4e9b850..c207aa2d 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -85,6 +85,25 @@ export function outdatedBanner(sources?: TableSource[]): string[] { }); } +/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */ +export interface ServedKind { + group: string; + kind: string; + label: string; + rows: number; +} + +/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */ +export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] { + const boxes: { group: string; items: ServedKind[] }[] = []; + for (const kind of kinds) { + const box = boxes.find((each) => each.group === kind.group); + if (box) box.items.push(kind); + else boxes.push({ group: kind.group, items: [kind] }); + } + return boxes; +} + /** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */ export interface SortOrder { key: string; diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index d077e77e..021e9198 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -13,7 +13,8 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import type { ServedKind } from "./Z01_MasterData_UI_Cells"; import { buildRowsView } from "./Z01_MasterData_UI_Rows"; import { buildSidePanel } from "./Z01_MasterData_UI_Side"; import "./Z01_MasterData_UI_Style.css"; @@ -27,24 +28,25 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { } showLoadingOverlay(); let groups: MasterGroup[] = []; + let kinds: ServedKind[] = []; try { - groups = await fetchMasterTree(); + [groups, kinds] = await Promise.all([fetchMasterTree(), fetchBaseKinds()]); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } root.innerHTML = ""; - root.append(buildPage(groups)); + root.append(buildPage(groups, kinds)); } -function buildPage(groups: MasterGroup[]): HTMLElement { +function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement { const view = buildRowsView(); const layout = el("div", { className: "ui-workflow-layout z01-master" }); const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - optionsContent: buildSidePanel(groups, view), + optionsContent: buildSidePanel(groups, kinds, view), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index a0fb7fee..d245d1ee 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -10,55 +10,62 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { - BASE_PRICE_KINDS, fetchBasePrices, fetchMasterRows, fetchOverrides, type MasterGroup, - type BasePriceKind, type MasterRows, type RowsQuery, saveBasePrice, - SPEC_KINDS, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; -import { valueWithUnit, type OverrideState } from "./Z01_MasterData_UI_Cells"; +import { + groupKinds, + valueWithUnit, + type OverrideState, + type ServedKind, +} from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; -export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { +export function buildSidePanel( + groups: MasterGroup[], + kinds: ServedKind[], + view: RowsView, +): HTMLElement { const panel = el("div", { className: "z01-master__side" }); const activate = (button: HTMLElement): void => { panel.querySelector(".is-active")?.classList.remove("is-active"); button.classList.add("is-active"); }; - const baseTitle = L("Z01_MasterData_BasePrices"); - const specTitle = L("Z01_MasterData_SpecValues"); - /** 한 상자 몫 단추들 — ①얼마인가 ②얼마나 드나 로 갈라 세움(성격도 갱신 주기도 다름). */ - const kindGrid = (boxTitle: string, kinds: readonly BasePriceKind[]): HTMLElement => { + // 갈래·kind·이름·줄 수 모두 서버 것 — 화면은 목록도 이름도 안 가짐(kind 가 늘면 저절로 섬). + const boxes = groupKinds(kinds).map((box) => { const grid = el("div", { className: "z01-master__base-grid" }); - for (const kind of kinds) { - const label = L(`Z01_MasterData_Base_${kind}` as const); + for (const served of box.items) { const button = el("button", { className: "z01-master__base", attrs: { type: "button" }, - text: label, + children: [ + el("span", { text: served.label }), + el("span", { + className: "z01-master__key", + text: served.rows.toLocaleString("ko-KR"), + }), + ], }); button.addEventListener("click", () => { activate(button); view.show({ - title: `${boxTitle} › ${label}`, - key: `base-prices/${kind}`, - load: (query) => fetchBasePrices(kind, query), - save: (rowId, values) => saveBasePrice(kind, rowId, values), + title: `${box.group} › ${served.label}`, + key: `base-prices/${served.kind}`, + load: (query) => fetchBasePrices(served.kind, query), + save: (rowId, values) => saveBasePrice(served.kind, rowId, values), }); }); grid.append(button); } - return grid; - }; - const baseGrid = kindGrid(baseTitle, BASE_PRICE_KINDS); - const specGrid = kindGrid(specTitle, SPEC_KINDS); + return { group: box.group, grid }; + }); const overridesButton = el("button", { className: "z01-master__base z01-master__base--wide", attrs: { type: "button" }, @@ -67,13 +74,14 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme overridesButton.addEventListener("click", () => { activate(overridesButton); view.show({ - title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, + title: L("Z01_MasterData_Overrides"), key: "overrides", searchable: false, - load: loadOverrides, + // 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임. + load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))), }); }); - baseGrid.append(overridesButton); + boxes[0]?.grid.append(overridesButton); const tree = el("nav", { className: "z01-master__tree" }); for (const group of groups) { @@ -123,8 +131,7 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme } panel.append( - section(baseTitle, baseGrid), - section(specTitle, specGrid), + ...boxes.map((box) => section(box.group, box.grid)), section(L("Z01_MasterData_Tree"), tree), ); attachCollapsible(panel); @@ -142,13 +149,16 @@ const STATE_LABEL: Record[0]> = { * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. * 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김. */ -async function loadOverrides(query: RowsQuery): Promise { +async function loadOverrides( + query: RowsQuery, + kindLabels: Map, +): Promise { const { items, total } = await fetchOverrides(query); const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); return { columns: [ column("state", "Z01_MasterData_List_State"), - column("kind", "Z01_MasterData_BasePrices"), + column("kind", "Z01_MasterData_List_Kind"), column("row_label", "Z01_MasterData_List_Row"), column("column_label", "Z01_MasterData_List_Column"), column("value", "Z01_MasterData_List_Value"), @@ -164,7 +174,7 @@ async function loadOverrides(query: RowsQuery): Promise { : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), - kind: L(`Z01_MasterData_Base_${item.kind}` as const), + kind: kindLabels.get(item.kind) ?? item.kind, })), total, }; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index a88ad823..28d71e34 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,20 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - boldSegments, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, + boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인) +const served = [ + { group: "base_price", kind: "labor", label: "노임", rows: 261 }, + { group: "base_price", kind: "oil", label: "유가", rows: 36 }, + { group: "pumsem_basis", kind: "coef", label: "토량환산계수", rows: 32 }, + // 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움 + { group: "pumsem_basis", kind: "machine_productivity", label: "기계 작업량", rows: 47 }, + { group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 }, +]; +const grouped = groupKinds(served).map((g) => [g.group, g.items.map((i) => i.kind)]); + // 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터 const sorts = [ nextSort(null, "price_krw"), @@ -193,6 +204,7 @@ writeFileSync(process.argv[2], JSON.stringify({ notices, bolds, sorts, + grouped, })); """ @@ -311,3 +323,9 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: None, {"key": "item_code", "desc": False}, ] + # 서버 차례 그대로 갈래별로 묶임 · 새 kind·새 갈래도 저절로 섬(화면 목록이 없음) + assert got["grouped"] == [ + ["base_price", ["labor", "oil"]], + ["pumsem_basis", ["coef", "machine_productivity"]], + ["later_group", ["brand_new"]], + ] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 810d777a..ccc0850a 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -141,24 +141,8 @@ export const ui_locales_b3 = { B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], Z01_MasterData_Title: ["마스터 데이터", "Master Data"], Z01_MasterData_Tree: ["마스터 트리", "Master tree"], - Z01_MasterData_BasePrices: ["기초단가", "Base prices"], - Z01_MasterData_Base_labor: ["노임단가", "Labor"], - Z01_MasterData_Base_machine: ["기계단가", "Machine"], - Z01_MasterData_Base_material: ["자재단가", "Material"], - Z01_MasterData_Base_oil: ["유가", "Fuel"], - Z01_MasterData_Base_rate: ["요율", "Rates"], - /* ② 품셈 기준 아홉 — 얼마나 드나(품셈이 정하고 해마다 한 번 개정) */ - Z01_MasterData_SpecValues: ["품셈 기준", "Standard rates"], - Z01_MasterData_Base_coef: ["토량환산", "Soil conversion"], - Z01_MasterData_Base_material_surcharge: ["자재할증", "Material surcharge"], - Z01_MasterData_Base_formwork_reuse: ["거푸집", "Formwork reuse"], - Z01_MasterData_Base_rebar_complexity: ["철근", "Rebar"], - Z01_MasterData_Base_timber_structure_class: ["목구조", "Timber structure"], - Z01_MasterData_Base_masonry_slope: ["돌쌓기 경사", "Masonry slope"], - Z01_MasterData_Base_masonry_back_length: ["뒷길이", "Back length"], - Z01_MasterData_Base_stone_kind: ["돌종류", "Stone kind"], - Z01_MasterData_Base_machine_productivity: ["기계 작업량", "Machine productivity"], - Z01_MasterData_Base_masonry_class: ["갈래", "Masonry class"], + /* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */ + Z01_MasterData_List_Kind: ["표", "Table"], Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)",