Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd41590da | ||
|
|
3a5132d905 | ||
|
|
177534709f | ||
|
|
148ea9e6a1 | ||
|
|
26a847babc | ||
|
|
35632f8cb9 | ||
|
|
5a05b9b525 | ||
|
|
81a1c013d2 | ||
|
|
d8f4ac0240 | ||
|
|
5ae246146c | ||
|
|
0749489122 | ||
|
|
88007b1ad9 | ||
|
|
2942fcd595 | ||
|
|
fbf7a68df1 | ||
|
|
9424cd2129 | ||
|
|
df2b62a34e | ||
|
|
8fb1952ba9 | ||
|
|
e412685bf4 | ||
|
|
f2bfaf888d | ||
|
|
3a09a51a39 | ||
|
|
da2238505c | ||
|
|
e130a7f7ec | ||
|
|
e3303b9136 | ||
|
|
ca2662ff7a | ||
|
|
05c9d1e1e6 | ||
|
|
5e7ec39c69 | ||
|
|
5eda05bd43 | ||
|
|
399b219371 | ||
|
|
357c166c34 | ||
|
|
be9effde6d | ||
|
|
827054f8b1 | ||
|
|
7797a2ffc1 | ||
|
|
a938b53b43 | ||
|
|
844f1a9700 | ||
|
|
7150fd8289 |
@@ -26,6 +26,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
|||||||
ROUTES.B09_ESTIMATION,
|
ROUTES.B09_ESTIMATION,
|
||||||
ROUTES.B10_PAYMENT,
|
ROUTES.B10_PAYMENT,
|
||||||
ROUTES.B11_STATUS,
|
ROUTES.B11_STATUS,
|
||||||
|
ROUTES.Z01_MASTER_DATA,
|
||||||
];
|
];
|
||||||
|
|
||||||
function bootstrap(): void {
|
function bootstrap(): void {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
|||||||
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
|
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
|
||||||
[ROUTES.B11_LOADING]: async () =>
|
[ROUTES.B11_LOADING]: async () =>
|
||||||
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
||||||
|
[ROUTES.Z01_MASTER_DATA]: async () =>
|
||||||
|
(await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 로그인 여부 (토큰 존재 확인) */
|
/** 로그인 여부 (토큰 존재 확인) */
|
||||||
|
|||||||
@@ -130,6 +130,19 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function buildPage(state: DashboardState): HTMLElement {
|
||||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||||
const page = document.createElement("div");
|
const page = document.createElement("div");
|
||||||
@@ -138,8 +151,15 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
grid.className = "b01-dashboard__grid";
|
grid.className = "b01-dashboard__grid";
|
||||||
|
|
||||||
if (state.user.role === "SYSTEM_ADMIN") {
|
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(
|
grid.append(
|
||||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
topRow,
|
||||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.b01-dashboard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -280,7 +288,8 @@
|
|||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.b01-dashboard__header,
|
.b01-dashboard__header,
|
||||||
.b01-dashboard__grid {
|
.b01-dashboard__grid,
|
||||||
|
.b01-dashboard__top-row {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* Z01_MasterData_Api_Fetch.ts
|
||||||
|
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
|
||||||
|
*
|
||||||
|
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
import type { MasterColumn, OverrideState, TableMeta } 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 const VALUES_TABLE_ID = "@values";
|
||||||
|
|
||||||
|
export interface MasterGroup {
|
||||||
|
/** fingerprint = 폴더 지문(_manifest) — 어느 판으로 셈했는지 못박는 자리(부산물 아님). */
|
||||||
|
key: "logic" | "base_value" | "byproduct" | "seed" | "fingerprint";
|
||||||
|
label: string;
|
||||||
|
files: MasterFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
|
||||||
|
export interface MasterRows extends TableMeta {
|
||||||
|
columns: MasterColumn[];
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
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<string, string> => ({
|
||||||
|
page: String(query.page),
|
||||||
|
size: String(query.size),
|
||||||
|
q: query.q,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<MasterRows> {
|
||||||
|
const params = new URLSearchParams(pageParams(query));
|
||||||
|
return request<MasterRows>(`/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<OverrideItem[]> {
|
||||||
|
return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
|
||||||
|
export function saveBasePrice(
|
||||||
|
kind: BasePriceKind,
|
||||||
|
rowId: string,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMasterTree(): Promise<MasterGroup[]> {
|
||||||
|
return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMasterRows(
|
||||||
|
file: string,
|
||||||
|
table: string,
|
||||||
|
query: RowsQuery,
|
||||||
|
): Promise<MasterRows> {
|
||||||
|
const params = new URLSearchParams({ file, table, ...pageParams(query) });
|
||||||
|
return request<MasterRows>(`/master-data/rows?${params}`);
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""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 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()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
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)}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Z01 마스터 데이터 — `resources/` 마스터 JSON 을 **갈래 → 파일 → 표 → 줄** 로 가름(읽기 전용).
|
||||||
|
|
||||||
|
마스터 = 로직 + 기초값(2026-09-15 사용자 방향 · bottom-up).
|
||||||
|
**이름표 파일**(`resources/data_master_labels/labels_*.json` · 데스크탑 서브)이 파일 목록·갈래·한글 이름·단위·보임의 자리 —
|
||||||
|
여기는 그 목록의 파일을 읽어 표를 찾고 줄을 냄. 갈래표를 코드에 또 두지 않음(두 벌 금지).
|
||||||
|
표 = **화면이 표로 그릴 마디** — 이름표 생성기와 같은 잣대(브레인 「91 이 정본」):
|
||||||
|
· 딕셔너리 목록(비지 않음) → 줄 = 딕셔너리 그대로({열key: 값})
|
||||||
|
· 딕셔너리 둘 이상이 모인 딕셔너리(열이 비슷함) → 줄마다 `@key` 열(그 줄의 이름) + 값 딕셔너리
|
||||||
|
· 표 아닌 마디(값 묶음 — 산재 3.56% 같은 낱값)는 파일마다 표 하나 `@values`(이름 · 값) — 설명·방침은 뺌(브레인 ④)
|
||||||
|
⚠ 표 id 가 이름표와 한 글자라도 다르면 이름이 통째로 안 붙음 — `test_z01_master_data.py` 가 양쪽으로 셈.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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"
|
||||||
|
VALUES_TABLE = "@values"
|
||||||
|
|
||||||
|
# 값 묶음 갈래 — 이름표 `value_groups[].kind`(데스크탑 서브 8fb1952b) · 낱말이 바뀌면 이 자리만 갈아끼움
|
||||||
|
ROLE_FIELD = "kind"
|
||||||
|
SHOWN_ROLE = "value" # 원가·수량에 드는 값 — 낱값 표에 보임
|
||||||
|
HIDDEN_ROLE = "doc" # 읽으라고 적은 글 — 안 보임
|
||||||
|
# 이름표가 안 가른(또는 모르는 낱말) 마디 — 조용히 빼지도 섞지도 않고 값으로 보이되 드러냄(브레인)
|
||||||
|
UNDECIDED_COLUMN = "@undecided"
|
||||||
|
UNDECIDED = "미판정"
|
||||||
|
|
||||||
|
|
||||||
|
# 파일 머리 — 표도 값 묶음도 아님(이름표 생성기와 같은 목록)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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 _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 _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
|
||||||
|
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, values, depth + 1)
|
||||||
|
else:
|
||||||
|
values["/".join(path)] = node
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
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, values)
|
||||||
|
return out, values
|
||||||
|
|
||||||
|
|
||||||
|
def tables_of(path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""표 id(이름표의 표 key · `/` 로 이은 자리) → 줄."""
|
||||||
|
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(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():
|
||||||
|
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 {}
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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 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()
|
||||||
|
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 []}
|
||||||
|
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()}
|
||||||
|
value_count = len(_value_rows(entry, 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": n}
|
||||||
|
for tid, n in counts.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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. 검색은 줄 값 글자에 든 것(대소문자 무시)."""
|
||||||
|
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
|
||||||
|
if path and tid == VALUES_TABLE:
|
||||||
|
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 = (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:
|
||||||
|
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 table
|
||||||
|
)
|
||||||
|
size = max(1, min(size, MAX_PAGE_SIZE))
|
||||||
|
start = (max(page, 1) - 1) * size
|
||||||
|
columns = []
|
||||||
|
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,
|
||||||
|
"label": named.get("name_ko") or key,
|
||||||
|
"unit": named.get("unit") or "",
|
||||||
|
"hidden": named.get("visible") is False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 줄마다 판의 기준일 — 기초단가 한 표에 시기가 다른 판이 섞여 줄마다 보임(2026-09-15 브레인). */
|
||||||
|
export const EFFECTIVE_DATE_KEY = "effective_date";
|
||||||
|
|
||||||
|
/** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */
|
||||||
|
export function rowCells(row: Record<string, unknown>, columns: MasterColumn[]): string[] {
|
||||||
|
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 브레인 ④). */
|
||||||
|
export interface TableMeta {
|
||||||
|
editable?: string[];
|
||||||
|
/** 열key → 왜 못 고치나 */
|
||||||
|
locked?: Record<string, string>;
|
||||||
|
/** 열key → 식 자체 */
|
||||||
|
formula?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(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<string, unknown>, key: string): CellInfo {
|
||||||
|
const overrides = row["@overrides"] as
|
||||||
|
Record<string, { original: unknown; current?: unknown; state?: OverrideState }> | undefined;
|
||||||
|
const filled = row["@formula"] as Record<string, string> | 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 쪽 번호 칸에 적은 값을 1~끝 쪽으로 — 큰 표(800쪽)는 한 칸씩 넘길 수 없음. */
|
||||||
|
export function clampPage(value: number, pages: number): number {
|
||||||
|
return Number.isFinite(value) ? Math.min(pages, Math.max(1, Math.round(value))) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 요청의 응답만 — 검색 글자가 바뀌어 옛 요청이 늦게 오면 새 표를 덮음.
|
||||||
|
* 옛 요청은 undefined 로 끝나고(오류도 삼킴) 최신 요청의 오류만 던짐.
|
||||||
|
*/
|
||||||
|
export function latestOnly<A extends unknown[], T>(
|
||||||
|
fn: (...args: A) => Promise<T>,
|
||||||
|
): (...args: A) => Promise<T | undefined> {
|
||||||
|
let seq = 0;
|
||||||
|
return async (...args) => {
|
||||||
|
const mine = ++seq;
|
||||||
|
try {
|
||||||
|
const value = await fn(...args);
|
||||||
|
return mine === seq ? value : undefined;
|
||||||
|
} catch (error) {
|
||||||
|
if (mine === seq) throw error;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* Z01_MasterData_UI_Page.ts
|
||||||
|
* 마스터 데이터 — B03~B09 와 같은 틀: 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표
|
||||||
|
*
|
||||||
|
* 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 워크플로 밖 화면이라 진행단계
|
||||||
|
* 패널·프로젝트 이름은 안 세움 (2026-09-15 브레인 Z01 — 좌측 패널 양식 갈음 · 기초단가 칸).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import "@ui/ui_template_workflow_layout.css";
|
||||||
|
import { ROUTES } from "@config/config_frontend";
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
|
||||||
|
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 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),
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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,
|
||||||
|
clampPage,
|
||||||
|
columnTitle,
|
||||||
|
latestOnly,
|
||||||
|
pageCount,
|
||||||
|
parseCellInput,
|
||||||
|
rowCells,
|
||||||
|
shownColumns,
|
||||||
|
valueWithUnit,
|
||||||
|
type CellInfo,
|
||||||
|
type MasterColumn,
|
||||||
|
} from "./Z01_MasterData_UI_Cells";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const SEARCH_DELAY_MS = 300;
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface RowsSource {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
/** 표 위 알림 한 줄(요율 — 법이 정한 값) */
|
||||||
|
notice?: string;
|
||||||
|
load: (query: RowsQuery) => Promise<MasterRows>;
|
||||||
|
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
|
||||||
|
save?: (rowId: string, values: Row) => Promise<Row>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowsView {
|
||||||
|
root: HTMLElement;
|
||||||
|
show: (source: RowsSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사전 문구의 {value} 자리에 값을 넣음. */
|
||||||
|
function fill(key: Parameters<typeof L>[0], value: string): string {
|
||||||
|
return L(key).replace("{value}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */
|
||||||
|
function stateText(info: CellInfo, column: MasterColumn): string {
|
||||||
|
const original = valueWithUnit(info.original, column.unit);
|
||||||
|
switch (info.state) {
|
||||||
|
case "source_changed":
|
||||||
|
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":
|
||||||
|
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<typeof L>[0], onClick: () => void) =>
|
||||||
|
createButton({ label: L(key), variant: "ghost", onClick });
|
||||||
|
const first = pagerButton("Z01_MasterData_First", () => goTo(1));
|
||||||
|
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
|
||||||
|
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
|
||||||
|
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
|
||||||
|
const toolbar = el("div", {
|
||||||
|
className: "z01-master__toolbar",
|
||||||
|
children: [search.root, hiddenLabel],
|
||||||
|
});
|
||||||
|
const pager = el("div", {
|
||||||
|
className: "z01-master__pager",
|
||||||
|
children: [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<void> {
|
||||||
|
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;
|
||||||
|
grid.querySelector("td.is-selected")?.classList.remove("is-selected");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식.
|
||||||
|
* `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③).
|
||||||
|
*/
|
||||||
|
function selectCell(index: number, column: MasterColumn, failure?: string): void {
|
||||||
|
if (!last) return;
|
||||||
|
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(
|
||||||
|
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", 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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const why = fill("Z01_MasterData_Edit_Invalid", input.value);
|
||||||
|
showToast(why, "error");
|
||||||
|
draw();
|
||||||
|
selectCell(index, column, why);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (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<void> {
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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 { valueWithUnit } from "./Z01_MasterData_UI_Cells";
|
||||||
|
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<MasterRows> {
|
||||||
|
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<typeof L>[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,
|
||||||
|
// 값은 쉼표를 붙여 — 목록 줄에는 단위가 안 실려 옴.
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 트리 접는 칸 — 브라우저 기본 <details> 로 둠. */
|
||||||
|
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] });
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/* Z01 마스터 데이터 — 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 */
|
||||||
|
.z01-master [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 좌측 패널: 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: 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 {
|
||||||
|
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-4);
|
||||||
|
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__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-12);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--spacing-16) var(--spacing-24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z01-master__title {
|
||||||
|
display: inline;
|
||||||
|
color: var(--color-text);
|
||||||
|
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;
|
||||||
|
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__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 - 320px);
|
||||||
|
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__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;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-12);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z01-master__page-input {
|
||||||
|
width: 6em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ export const ROUTES = {
|
|||||||
B11_STATUS: "b11-status",
|
B11_STATUS: "b11-status",
|
||||||
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
|
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
|
||||||
B11_LOADING: "b11-loading",
|
B11_LOADING: "b11-loading",
|
||||||
|
// 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01).
|
||||||
|
Z01_MASTER_DATA: "z01-master-data",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RouteKey = keyof typeof ROUTES;
|
export type RouteKey = keyof typeof ROUTES;
|
||||||
@@ -138,6 +140,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
|
|||||||
ROUTES.B10_PAYMENT,
|
ROUTES.B10_PAYMENT,
|
||||||
ROUTES.B11_STATUS,
|
ROUTES.B11_STATUS,
|
||||||
ROUTES.B11_LOADING,
|
ROUTES.B11_LOADING,
|
||||||
|
ROUTES.Z01_MASTER_DATA,
|
||||||
];
|
];
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_r
|
|||||||
from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
|
from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
|
||||||
router as b09_material_prices_router,
|
router as b09_material_prices_router,
|
||||||
)
|
)
|
||||||
|
from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router
|
||||||
from common_util.common_util_audit import note_api_call, record_call_burst
|
from common_util.common_util_audit import note_api_call, record_call_burst
|
||||||
from common_util.common_util_auth import (
|
from common_util.common_util_auth import (
|
||||||
require_company,
|
require_company,
|
||||||
require_project_access,
|
require_project_access,
|
||||||
|
require_system_admin,
|
||||||
verify_session,
|
verify_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -654,6 +656,9 @@ app.include_router(b09_progress_router, dependencies=protected_with_company)
|
|||||||
app.include_router(b09_edits_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_factors_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_material_prices_router, dependencies=protected_with_company)
|
app.include_router(b09_material_prices_router, dependencies=protected_with_company)
|
||||||
|
# Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01).
|
||||||
|
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||||
|
app.include_router(z01_master_data_router, dependencies=system_admin_only)
|
||||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||||
# 그 위에 서버가 환경까지 한 번 더 본다.
|
# 그 위에 서버가 환경까지 한 번 더 본다.
|
||||||
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
"""마스터 이름표(`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
|
||||||
|
|
||||||
|
|
||||||
|
#: 사전 모양 표의 **줄 이름 열** — 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] = [ROW_KEY_COLUMN] if shape == "map" else []
|
||||||
|
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):
|
||||||
|
"""`_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"]:
|
||||||
|
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"]]
|
||||||
|
|
||||||
|
|
||||||
|
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_값_묶음마다_kind_가_낱말로_적혀_있다(labels):
|
||||||
|
known = set(labels["value_kinds"])
|
||||||
|
bad = []
|
||||||
|
for entry in labels["files"]:
|
||||||
|
for group in entry["value_groups"]:
|
||||||
|
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_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", "@undecided"]
|
||||||
|
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["kind"] 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") == "doc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_세어_둔_값_갈래_수가_실제와_같다(labels):
|
||||||
|
counted: dict[str, int] = {}
|
||||||
|
for entry in labels["files"]:
|
||||||
|
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"] == "미판정"
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -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
|
||||||
@@ -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"]) # 없는 칸을 새로 만들지 않음
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Z01 마스터 데이터 표 칸 — 숨김 열 · 열 제목(한글 label + 단위) · 칸 글자 · 쪽 수 (2026-09-15 브레인 Z01).
|
||||||
|
|
||||||
|
① 한글 이름은 API 가 `label` 로 줌 — 화면은 사전을 안 가짐(받은 대로 보임)
|
||||||
|
② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임
|
||||||
|
③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로
|
||||||
|
④ 쪽 수는 줄이 없어도 1
|
||||||
|
⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데
|
||||||
|
줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인)
|
||||||
|
⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감)
|
||||||
|
⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인)
|
||||||
|
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 {
|
||||||
|
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
|
||||||
|
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"],
|
||||||
|
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);
|
||||||
|
// 옛 검색(느림) → 새 검색(빠름) · 옛 오류(느림) → 새 성공(빠름)
|
||||||
|
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 },
|
||||||
|
{ 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)],
|
||||||
|
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,
|
||||||
|
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)),
|
||||||
|
editions,
|
||||||
|
withUnit: [
|
||||||
|
valueWithUnit(17, "원"),
|
||||||
|
valueWithUnit(41500000, "원"),
|
||||||
|
valueWithUnit(0.17, ""),
|
||||||
|
valueWithUnit(12345.678, null),
|
||||||
|
valueWithUnit(null, "원"),
|
||||||
|
valueWithUnit("5억 미만", ""),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
# 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"
|
||||||
|
# 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식
|
||||||
|
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]
|
||||||
|
# 기준일이 없으면 빈칸·「—」가 아니라 「판 모름」
|
||||||
|
assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"]
|
||||||
|
# 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만
|
||||||
|
assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"]
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판.
|
||||||
|
|
||||||
|
약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 ·
|
||||||
|
표 = 화면이 표로 그릴 마디(이름표 91 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
|
||||||
|
열 이름 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · 줄 한 줄 = {열key: 값} · file·table 은 트리의 id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
LABELS = json.loads(
|
||||||
|
(tables.LABEL_DIR / "labels_2026-01-01.json").read_text(encoding="utf-8")
|
||||||
|
) # 서브 이름표 판
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
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 _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] == [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_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: TestClient) -> None:
|
||||||
|
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"]]}
|
||||||
|
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(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"]]:
|
||||||
|
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:
|
||||||
|
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"]
|
||||||
|
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=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 [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"
|
||||||
|
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:
|
||||||
|
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_찾는_차례와_없는_이름은_영문_key(
|
||||||
|
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "labels_2026-01-01.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
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 (
|
||||||
|
client.get("/api/master-data/rows", params={"file": "outside", "table": "x"}).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_낱값은_파일마다_표_하나_설명은_뺌(client: TestClient) -> None:
|
||||||
|
"""브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 값/설명 가름은 이름표 `kind`."""
|
||||||
|
files = _files(client)
|
||||||
|
rates = files["rates"]["tables"]
|
||||||
|
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["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["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"]}
|
||||||
|
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["kind"] # 이름표가 뒤처짐
|
||||||
|
elif g["key"] == "variables/rate_care":
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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"]:
|
||||||
|
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, 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"},
|
||||||
|
{"file": "rates", "table": "nope"},
|
||||||
|
{"file": "rates", "table": "variables/rate_sanjae"}, # 값 묶음 — 표 아님
|
||||||
|
{"file": "../../config/config_db", "table": "rows"},
|
||||||
|
{"file": "002yr_01hr", "table": "features"}, # 강우 IDF 캐시 — 이름표 밖
|
||||||
|
):
|
||||||
|
assert client.get("/api/master-data/rows", params=params).status_code == 404, params
|
||||||
@@ -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
|
||||||
+1
-1
@@ -32,6 +32,6 @@
|
|||||||
"maplibre-gl": ["config/node_modules/maplibre-gl"],
|
"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"]
|
"exclude": ["0_old", "node_modules", "venv", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,4 +136,79 @@ export const ui_locales_b3 = {
|
|||||||
"법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)",
|
"법령 별표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)",
|
"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_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: [
|
||||||
|
"「{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"],
|
||||||
|
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"],
|
||||||
|
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_First: ["처음", "First"],
|
||||||
|
Z01_MasterData_Last: ["끝", "Last"],
|
||||||
|
Z01_MasterData_Prev: ["이전", "Prev"],
|
||||||
|
Z01_MasterData_Next: ["다음", "Next"],
|
||||||
|
Z01_MasterData_Page: ["쪽", "pages"],
|
||||||
|
Z01_MasterData_Rows: ["줄", "rows"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos";
|
|||||||
|
|
||||||
export interface WorkflowOverlayOptions {
|
export interface WorkflowOverlayOptions {
|
||||||
title: string;
|
title: string;
|
||||||
progressContent: HTMLElement;
|
/** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */
|
||||||
|
progressContent?: HTMLElement;
|
||||||
optionsContent?: HTMLElement;
|
optionsContent?: HTMLElement;
|
||||||
showTitlePanel?: boolean;
|
showTitlePanel?: boolean;
|
||||||
|
/** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */
|
||||||
|
showProjectName?: boolean;
|
||||||
onOptionsOpenChange?: (isOpen: boolean) => void;
|
onOptionsOpenChange?: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
|
|||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.className = "ui-workflow-overlay";
|
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 => {};
|
let setTitleOpen = (_isOpen: boolean): void => {};
|
||||||
if (options.showTitlePanel !== false) {
|
if (options.showTitlePanel !== false) {
|
||||||
const titleBody = document.createElement("div");
|
const titleBody = document.createElement("div");
|
||||||
@@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
|
|||||||
titleBody,
|
titleBody,
|
||||||
TITLE_OVERLAY_STATE_KEY,
|
TITLE_OVERLAY_STATE_KEY,
|
||||||
options.onOptionsOpenChange,
|
options.onOptionsOpenChange,
|
||||||
createProjectNameTag(),
|
options.showProjectName === false ? undefined : createProjectNameTag(),
|
||||||
);
|
);
|
||||||
root.append(titlePanel.root);
|
root.append(titlePanel.root);
|
||||||
setTitleOpen = titlePanel.setOpen;
|
setTitleOpen = titlePanel.setOpen;
|
||||||
}
|
}
|
||||||
|
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);
|
root.append(progressPanel.root);
|
||||||
|
setProgressOpen = progressPanel.setOpen;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return { root, setTitleOpen, setProgressOpen };
|
||||||
root,
|
|
||||||
setTitleOpen,
|
|
||||||
setProgressOpen: progressPanel.setOpen,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user