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

This commit is contained in:
2026-09-16 16:38:40 +09:00
46 changed files with 15591 additions and 389 deletions
+1
View File
@@ -26,6 +26,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B09_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.Z01_MASTER_DATA,
];
function bootstrap(): void {
+2
View File
@@ -59,6 +59,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
[ROUTES.B11_LOADING]: async () =>
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
[ROUTES.Z01_MASTER_DATA]: async () =>
(await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData,
};
/** 로그인 여부 (토큰 존재 확인) */
+21 -1
View File
@@ -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 {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
@@ -138,8 +151,15 @@ function buildPage(state: DashboardState): HTMLElement {
grid.className = "b01-dashboard__grid";
if (state.user.role === "SYSTEM_ADMIN") {
// 리소스 현황 오른쪽에 시스템 설정 — 앞으로 다른 설정 단추도 이 칸에 듦 (2026-09-15 브레인 Z01).
const topRow = document.createElement("div");
topRow.className = "b01-dashboard__top-row";
topRow.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources)),
section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel()),
);
grid.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
topRow,
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
]),
+10 -1
View File
@@ -31,6 +31,14 @@
grid-column: 1 / -1;
}
/* 리소스 현황 | 시스템 설정 — 설정 칸은 단추만 들어 좁게. */
.b01-dashboard__top-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
gap: var(--spacing-24);
}
.b01-dashboard__actions {
display: flex;
flex-wrap: wrap;
@@ -280,7 +288,8 @@
@media (max-width: 860px) {
.b01-dashboard__header,
.b01-dashboard__grid {
.b01-dashboard__grid,
.b01-dashboard__top-row {
display: block;
}
@@ -0,0 +1,68 @@
"""B09 — 기계 작업량 **밑값**을 마스터에서 읽는다(2026-09-16 사용자 원칙 · 브레인 지시).
마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨.**
그동안 불도저 속도·삽날 용량(건설품셈 8-2-1)과 덤프 운반·적재 식 계수(산림품셈 10-12)가
코드 상수로 박혀 있었다. 값은 그대로 두고 자리만 옮긴다 —
`resources/data_machine_productivity/machine_productivity_<판>.json`.
⚠ **식은 여기 없다.** `Q = n·q·f·E` 같은 식은 값이 아니라 로직이라 계산 모듈에 남는다.
⚠ 옮기면서 값을 하나도 안 바꿨다 — 원문(pum_const C0426·C0427)과의 대조는 시험이 지킨다.
"""
from __future__ import annotations
import json
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
DATA_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_machine_productivity"
PREFIX = "machine_productivity_"
class MachineProductivityDataError(FileNotFoundError):
"""밑값 판이 없다 — 지어내지 않고 멈춘다."""
@lru_cache(maxsize=2)
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def load() -> dict[str, Any]:
"""끝 판 한 벌(이름 차례)."""
found = sorted(DATA_DIR.glob(PREFIX + "*.json")) if DATA_DIR.is_dir() else []
if not found:
raise MachineProductivityDataError(f"기계 작업량 밑값 판이 없습니다: {DATA_DIR}")
return _read(str(found[-1]), found[-1].stat().st_mtime_ns)
def records(variable: str) -> list[dict[str, Any]]:
return load()["variables"][variable]["records"]
def dozer_speeds() -> dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]]:
"""{무한궤도·타이어: {규격(ton): {단: (전진, 후진)}}} — 후진이 원문에 없는 단은 담기지 않는다."""
out: dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]] = {}
for row in records("dozer_speed"):
track = out.setdefault(row["track"], {})
gears = track.setdefault(Decimal(row["tonnage_ton"]), {})
gears[int(row["gear"])] = (
Decimal(row["forward_m_per_min"]),
Decimal(row["reverse_m_per_min"]),
)
return out
def dozer_blades() -> dict[tuple[str, Decimal], Decimal]:
"""{(무한궤도·타이어, 규격(ton)): 삽날 용량 q˚(㎥)}."""
return {
(row["track"], Decimal(row["tonnage_ton"])): Decimal(row["blade_m3"])
for row in records("dozer_blade")
}
def haul_params() -> dict[str, Decimal]:
"""덤프 운반·적재 식이 쓰는 밑값(적재 규격 T · 버켓 q · 싸이클 · 주행속도 · 적하·대기·덮개)."""
return {row["key"]: Decimal(row["value"]) for row in records("dump_haul")}
@@ -23,6 +23,7 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from B09_Estimation import B09_Estimation_MachineProductivity_Data as data
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
@@ -38,47 +39,13 @@ _ZERO = Decimal(0)
#: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.**
#: ⭐ 값은 마스터 `resources/data_machine_productivity/` 에 있다 — 여기 박지 않는다(사용자 원칙).
#: {규격(ton): {단: (전진, 후진)}}
#: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은
#: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다.
#: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반
#: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다.
_DOZER_SPEEDS = {
"무한궤도": {
Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))},
Decimal("7"): {
1: (Decimal(43), Decimal(53)),
2: (Decimal(67), Decimal(78)),
3: (Decimal(92), Decimal(107)),
},
Decimal("10"): {
1: (Decimal(42), Decimal(50)),
2: (Decimal(64), Decimal(75)),
3: (Decimal(88), Decimal(105)),
},
Decimal("12"): {
1: (Decimal(40), Decimal(48)),
2: (Decimal(55), Decimal(70)),
3: (Decimal(75), Decimal(100)),
},
Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))},
Decimal("19"): {
1: (Decimal(40), Decimal(46)),
2: (Decimal(55), Decimal(70)),
3: (Decimal(75), Decimal(98)),
},
Decimal("32"): {
1: (Decimal(40), Decimal(43)),
2: (Decimal(52), Decimal(58)),
3: (Decimal(70), Decimal(78)),
},
},
"타이어": {
Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))},
Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))},
Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))},
},
}
_DOZER_SPEEDS = data.dozer_speeds() # 마스터에서 읽음 — 값은 그대로(2026-09-16 꺼냄)
#: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」
_DOZER_GEAR_SHIFT_MIN = Decimal("0.25")
_MINUTES_PER_HOUR = Decimal(60)
@@ -144,18 +111,7 @@ def dozer_speeds(
#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다.
#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다.
_DOZER_BLADE_M3 = {
("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지
("무한궤도", Decimal("7")): Decimal("1.1"),
("무한궤도", Decimal("10")): Decimal("1.5"),
("무한궤도", Decimal("12")): Decimal("2.0"),
("무한궤도", Decimal("13")): Decimal("1.5"), # 습지
("무한궤도", Decimal("19")): Decimal("3.2"),
("무한궤도", Decimal("32")): Decimal("5.5"),
("타이어", Decimal("15")): Decimal("3.1"),
("타이어", Decimal("28")): Decimal("4.0"),
("타이어", Decimal("33")): Decimal("5.7"),
}
_DOZER_BLADE_M3 = data.dozer_blades() # 마스터에서 읽음
#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」.
_DOZER_WET_TONS = (Decimal("4"), Decimal("13"))
@@ -31,19 +31,23 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from B09_Estimation import B09_Estimation_MachineProductivity_Data as data
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
#: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15.
DUMP_TRUCK_CODE = "0602-0150"
DUMP_PARENT = "FP-10-12"
_TRUCK_TON = Decimal(15)
_BUCKET_M3 = Decimal("0.7")
_LOADER_CYCLE_SEC = Decimal(20)
_V_LOADED_KMH = Decimal(5)
_V_EMPTY_KMH = Decimal(6)
_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5")
_SIXTY = Decimal(60)
#: ⭐ 식이 쓰는 밑값은 **마스터**에 있다 — `resources/data_machine_productivity/`(2026-09-16 꺼냄).
#: 식(Q·㎝t·n)은 값이 아니라 로직이라 여기 남는다.
_HAUL = data.haul_params()
_TRUCK_TON = _HAUL["truck_ton"]
_BUCKET_M3 = _HAUL["bucket_m3"]
_LOADER_CYCLE_SEC = _HAUL["loader_cycle_sec"]
_V_LOADED_KMH = _HAUL["speed_loaded_kmh"]
_V_EMPTY_KMH = _HAUL["speed_empty_kmh"]
_T3_UNLOAD, _T4_WAIT, _T5_COVER = _HAUL["unload_min"], _HAUL["wait_min"], _HAUL["cover_min"]
_SIXTY = Decimal(60) # 분↔시간 환산 — 값이 아니라 단위
@dataclass(frozen=True)
@@ -66,37 +70,33 @@ _COMMON_NOTE = (
_ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
DUMP_MATERIALS: dict[str, DumpMaterial] = {
"FP-10-12-01": DumpMaterial(
"FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"),
Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE),
),
"FP-10-12-02": DumpMaterial(
"FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"),
Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE),
),
"FP-10-12-03": DumpMaterial(
"FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"),
Decimal("0.9"),
(_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"),
),
} # fmt: skip
row["work_item_code"]: DumpMaterial(
row["work_item_code"],
row["label"],
Decimal(row["unit_weight_ton_per_m3"]),
Decimal(row["loose_factor"]),
Decimal(row["bucket_factor"]),
Decimal(row["loader_efficiency"]),
Decimal(row["truck_efficiency"]),
tuple(row.get("notes") or ()),
)
for row in data.records("dump_material")
}
#: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°).
#: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤
#: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**.
LOADER_CODE = "0201-0070"
_LOADING_CYCLE_SEC = Decimal(22)
_LOADING_CYCLE_SEC = _HAUL["loading_cycle_sec"]
_LOADING_E0 = {
"FP-10-12-01": Decimal("0.60"),
"FP-10-12-02": Decimal("0.35"),
"FP-10-12-03": Decimal("0.35"),
row["work_item_code"]: Decimal(row["loading_efficiency"])
for row in data.records("dump_material")
}
#: 적재 E0 사유 — 마스터가 줄에 따로 들고 있음(운반 줄 사유와 섞지 않음).
_LOADING_NOTES = {
"FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림",
"FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)",
"FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)",
} # fmt: skip
row["work_item_code"]: row.get("loading_note", "") for row in data.records("dump_material")
}
def loading_output(code: str) -> Decimal:
@@ -26,7 +26,6 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
@@ -235,9 +234,8 @@ _CAPACITY_UNIT = re.compile(r"[(]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시
def _paired_machine(node: dict[str, Any]) -> tuple[str, str, str] | None:
"""그 표에 함께 나오는 기종 — (코드, 이름, 규격). 「유압식백호우 (…0.7㎥)」 → 0201-0070."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
catalog = load_machine_catalog()
for table in node.get("tables", []):
@@ -1100,6 +1100,7 @@ def build_unit_prices(
attach_work_items_ax(build)
# 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다.
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
from B09_Estimation.B09_Estimation_Transport import attach_transport
build.transport_notes = attach_transport(
@@ -1108,6 +1109,13 @@ def build_unit_prices(
distance_km=transport_distance_km,
road_key=transport_road,
)
# ⚠ 줄이 하나도 안 서면 **사유를 그 공종에 붙인다** — 안 그러면 수송비가 조용히 0 으로 빠진다
# (2026-09-16 조사: 거리가 비어 모든 프로젝트에서 안 서고 있었고 사유도 안 떴다).
# 문구는 아래 층 것 **그대로** 쓴다 — 여기서 새로 짓지 않는다(까닭은 그대로 올림).
if build.transport_notes and not any(
code.startswith(f"B-{TRANSPORT_CODE}") for code in build.book.titles
):
build.component_gaps[TRANSPORT_CODE] = build.transport_notes[0]
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
attach_dump_hauls(build, master, dump_haul_m)
+137
View File
@@ -0,0 +1,137 @@
/* =============================================================================
* Z01_MasterData_Api_Fetch.ts
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
*
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type {
MasterColumn,
OverrideState,
ServedKind,
SortOrder,
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;
/** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */
sort?: SortOrder | null;
}
/**
* 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움
* (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리).
*/
export type BasePriceKind = string;
export function fetchBaseKinds(): Promise<ServedKind[]> {
return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds);
}
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));
// 안 누르면 sort 를 안 보냄 — 서버가 원본 차례로 줌(요율·기계는 차례가 뜻을 담음).
if (query.sort) {
params.set("sort", query.sort.key);
params.set("desc", query.sort.desc ? "1" : "0");
}
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;
unit?: string;
state: OverrideState;
}
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */
export function fetchOverrides(
query: Pick<RowsQuery, "page" | "size">,
): Promise<{ items: OverrideItem[]; total: number }> {
const params = new URLSearchParams({ page: String(query.page), size: String(query.size) });
return request(`/master-data/overrides?${params}`);
}
/** 칸 고치기 — 값 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}`);
}
+579
View File
@@ -0,0 +1,579 @@
"""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 datetime import date
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_BasePrices_Tables as base_tables
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Tables as tables
#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC)
SOURCES_DIR = (
tables.RESOURCES / "data_master_sources"
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
_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",
)
_SCOPE_NOTICE = (
"여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
"(프로젝트는 만들 때 뜬 사본을 봄)"
)
_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 not None:
return path
found = sorted(tables.RESOURCES.glob(f"data_*/{file_id}*.json"))
if not found:
raise FileNotFoundError(f"원본 자리를 못 찾음: {file_id}")
return found[-1]
@lru_cache(maxsize=16)
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 edition_of(doc: dict[str, Any]) -> str | None:
"""판 기준일 — 자료마다 자리가 달라 **여기 한 곳**에서 뽑음(`effective_date` · 파생본은 `derived_from.effective_date`)."""
derived = doc.get("derived_from")
return doc.get("effective_date") or (
derived.get("effective_date") if isinstance(derived, dict) else None
)
def _date(text: Any) -> date | None:
try:
return date.fromisoformat(str(text))
except ValueError:
return None
def sources(kind: str) -> list[dict[str, Any]]:
"""표 수준 출처 — kind 하나에 판이 둘인 곳(노임·유가·기계)이 있어 목록.
`our_edition` 은 출처표 글자가 아니라 **실제 파일**에서 읽음(글자가 굳으면 또 뒤처짐) ·
`outdated` 판정은 서버만(최신 공표일 > 우리 판 · 둘 중 하나라도 모르면 None).
"""
found = sorted(SOURCES_DIR.glob("sources_*.json")) if SOURCES_DIR.is_dir() else []
if not found:
return []
listing = _read(str(found[-1]), found[-1].stat().st_mtime_ns)
out = []
for entry in listing.get("sources") or []:
if entry.get("kind") != kind:
continue
editions = []
for name in entry.get("files") or []:
path = (tables.ROOT / name).resolve()
if path.is_file() and path.is_relative_to(tables.RESOURCES):
editions.append(_date(edition_of(_read(str(path), path.stat().st_mtime_ns))))
ours = min((e for e in editions if e), default=None)
latest = _date(entry.get("latest_published"))
out.append(
{
"source_id": entry.get("source_id"),
"name": entry.get("name_ko") or entry.get("source_id"),
"publisher": entry.get("publisher") or "",
"where": entry.get("where_to_get") or "없음", # 안 지어냄(브레인)
"cycle": entry.get("cycle") or "",
"latest_published": latest.isoformat() if latest else None,
"our_edition": ours.isoformat() if ours else None,
"checked_at": entry.get("checked_on") or None,
"outdated": None if ours is None or latest is None else latest > ours,
}
)
return out
def _records(doc: dict[str, Any]) -> list[dict[str, Any]]:
return next(
v["records"] for v in doc["variables"].values() if isinstance(v.get("records"), list)
)
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,
"source": dataset,
"effective_date": edition_of(doc),
**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 = edition_of(doc)
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", "scope")}
common = {
"source": v.get("scope"),
"fuel": variable,
"effective_date": edition_of(doc), # 전국 · 지역 판 기준일이 다름
**head,
}
if "records" not in v:
rows.append(
{
"@id": f"{v.get('scope')}/{variable}",
"@source": name,
**common,
"price_krw_per_l": v.get("value"),
}
)
for r in v.get("records") or []:
rows.append(
{
"@id": f"{v.get('scope')}/{variable}/{r['sido_code']}",
"@source": name,
**common,
"region_code": r["sido_code"],
"region_name": r["sido_name"],
"price_krw_per_l": r["value"],
}
)
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": edition_of(doc),
**_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": edition_of(doc),
"base": v.get("base"),
**item,
}
)
return rows
_BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate}
class DuplicateRowError(ValueError):
"""열쇠가 같은 줄인데 값이 다름 — 합치면 틀린 값이 섬(브레인 조건 · 품셈 개정으로 갈리는 날)."""
def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""열쇠가 같고 **값도 같은** 줄은 한 줄로 — 원문 두 줄(coef 「암괴…점토」)이 그 자리.
값이 갈리면 멈춤: 원문이 개정돼 두 줄이 달라지는 날 조용히 한 줄로 합치면 안 됨.
"""
body = lambda r: { # noqa: E731 — 견줄 알맹이(붙인 사유·줄 수는 뺌)
k: v
for k, v in r.items()
if not k.startswith("@") and k not in ("source_rows", "duplicate_note")
}
first: dict[str, dict[str, Any]] = {}
out = []
for row in rows:
kept = first.get(row["@id"])
if kept is None:
first[row["@id"]] = row
out.append(row)
continue
if body(kept) != body(row):
raise DuplicateRowError(f"열쇠가 같은데 값이 다름: {row['@id']}")
kept["source_rows"] = kept.get("source_rows", 1) + 1
kept["duplicate_note"] = (
"원문 두 줄 · 값 같음 — 한 줄로 보임(원문 오기 가능성 · 임의 보정 없음)"
)
return out
def base_rows(kind: str) -> list[dict[str, Any]]:
"""덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈)."""
if kind == "machine":
doc, name = _doc("mach_base")
return machine.base_rows(name, edition_of(doc))
spec_of = base_tables.SPEC.get(kind)
if spec_of:
doc, name = _doc(spec_of["file"])
return merge_same_rows(spec_of["build"](doc, name))
return _BUILDERS[kind]()
def _machine_inputs() -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일."""
labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"]
oil = [
r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}"
]
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["price_krw_per_l"])) for r in oil},
oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil},
operating_date=edition_of(_doc("pum_const")[0]),
)
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)
class SortError(ValueError):
"""모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④)."""
def _order_key(value: Any) -> tuple:
"""섞인 칸도 세움 — 수 먼저, 그 뒤 글자."""
if isinstance(value, bool):
return (1, str(value))
if isinstance(value, (int, float)):
return (0, float(value))
if isinstance(value, (dict, list)):
return (1, json.dumps(value, ensure_ascii=False, default=str))
return (1, str(value))
def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[str, Any]]:
"""⚠ 빈 칸은 오름·내림 **어느 쪽이든 끝** — 미공표 노임 14 · 손료계수 없는 기계 226 이
첫 쪽을 통째로 채우면 표를 못 씀(브레인 ②)."""
filled = [r for r in rows if r.get(column) not in (None, "")]
empty = [r for r in rows if r.get(column) in (None, "")]
return sorted(filled, key=lambda r: _order_key(r[column]), reverse=desc) + empty
#: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인).
_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림"
#: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**.
#: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표)
#: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값)
_CLASS_NOTICE = (
"전면 기울기 기본값은 **교본 7-3 형식별 값** — 품셈 표준경사(직고·성토/절토 축)는 「돌쌓기 표준경사」 표에 따로 있음"
" · 같은 1:0.3 이라도 **축과 근거가 다름**(판정이 안 되는 자리에서만 이 값으로 섬)"
)
_EXTRA_NOTICE = {
"rate": _RATE_NOTICE,
"masonry_slope": _SLOPE_NOTICE,
"masonry_class": _CLASS_NOTICE,
}
def notice(kind: str) -> list[str]:
"""표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③)."""
return [_SCOPE_NOTICE] + ([_EXTRA_NOTICE[kind]] if kind in _EXTRA_NOTICE else [])
def spec(kind: str, columns: list[str]) -> dict[str, Any]:
"""표 수준 — editable · locked · formula."""
if kind == "machine":
return machine.spec(columns)
if kind in base_tables.SPEC:
spec_of = base_tables.SPEC[kind]
editable = [c for c in spec_of["editable"] if c in columns]
axis = {a for r in base_rows(kind) for a in r.get("@axis", ())}
told = spec_of.get("locked") or {} # 계산값처럼 까닭이 따로인 칸
return {
"editable": editable,
"locked": {
c: told.get(c) or (_KEY if c in axis else _SOURCE)
for c in columns
if c not in editable
},
"formula": {k: v for k, v in (spec_of.get("formula") or {}).items() if k in columns},
}
editable = {
"labor": ["daily_wage_krw"],
"material": ["price_krw"],
"oil": ["price_krw_per_l"],
"rate": [c for c in _RATE_EDITABLE if c in columns],
}[kind]
keys = {"labor": {"source", "occupation_code"}, "material": {"item_code"}}.get(kind, set())
if kind == "oil":
keys = {"source", "fuel", "region_code"}
if kind == "rate":
locked = {
c: "규칙·구간 칸(로직) — 원본 갱신으로만 바뀜" for c in columns if c not in editable
}
else:
locked = {c: _KEY if c in keys else _SOURCE for c in columns if c not in editable}
return {
"editable": editable,
"locked": locked,
"formula": {},
}
#: 갈래 둘 — 기초단가(원가 = 얼마인가) · 품셈 기준(수량 = 얼마나 드나). 화면은 이것으로 상자를 세움.
BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis"
def kind_group(kind: str) -> str:
return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP
def kind_label(kind: str) -> str:
"""kind 한글 이름 — 이름표 `merged_tables` 에서만(코드에 박지 않음 · 없으면 영문 그대로)."""
merged = tables.load_labels().get("merged_tables") or []
named = next((t for t in merged if t.get("key") in (kind, f"{kind}s")), {})
return named.get("name_ko") or kind
def kinds() -> list[dict[str, Any]]:
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
갈래 · kind · 한글 이름(이름표) · 줄 수.
"""
return [
{
"group": kind_group(kind),
"kind": kind,
"label": kind_label(kind),
"rows": len(rows(kind)),
}
for kind in KINDS
]
def row_label(kind: str, row: dict[str, Any] | None) -> str:
if row is None:
return ""
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('region_name') or '전국평균'}"
if kind in base_tables.SPEC: # 기초데이터 아홉 — 축 칸을 이어 사람이 읽을 한 줄로
return " · ".join(str(row[c]) for c in row.get("@axis", ()) if row.get(c) is not None)
named = (tables.load_labels().get("value_overrides") or {}).get(
f"rates::variables/{row['variable']}"
) or {}
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]:
"""열 이름 — 이름표 `merged_tables[kind]` → `columns[열key]` → 영문 key(이름은 이름표에만 · 브레인 ②)."""
labels = tables.load_labels()
merged = next(
(t for t in labels.get("merged_tables") or [] if t.get("key") in (kind, f"{kind}s")), {}
)
named = next((c for c in merged.get("columns") or [] if c.get("key") == key), None)
named = named or labels["columns"].get(key) or {}
return {
"key": key,
"label": named.get("name_ko") or key,
"unit": named.get("unit") or "",
"hidden": named.get("visible") is False,
}
#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨
_NOT_MEANING = ("effective_date", "date", "notice_datetime")
def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, Any]:
"""그 줄의 **값이 아닌 글자 칸**(이름·규격·단위·인도조건 …) — 이름표가 숨김 아니라 한 열만.
갱신으로 같은 코드에 다른 기준이 붙으면(노임 국가유산직종 개편 · 자재 KS 개정) 값만으로는 못 잡음.
"""
out = {}
for key, value in row.items():
if key.startswith("@") or key in editable or key in _NOT_MEANING or key.endswith("_date"):
continue
if isinstance(value, str) and value.strip() and not column_meta(kind, key)["hidden"]:
out[key] = value
return out
def public(row: dict[str, Any]) -> dict[str, Any]:
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌."""
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
def table(
kind: str,
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
q: str = "",
sort: str = "",
desc: bool = False,
) -> dict[str, Any]:
all_rows = rows(kind)
columns = _columns(kind, all_rows)
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
)
if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③)
if sort not in columns:
raise SortError(f"없는 열로 세울 수 없음: {sort}")
hits = _sorted(hits, sort, desc)
size = max(1, min(size, tables.MAX_PAGE_SIZE))
start = (max(page, 1) - 1) * size
return {
"columns": [column_meta(kind, c) for c in columns],
# 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림
"sortable": list(columns),
"rows": [public(r) for r in hits[start : start + size]],
"total": len(hits),
**spec(kind, columns),
"notice": notice(kind),
"source": sources(kind),
}
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 in (base.get("@axis") or ()):
return 400, {"message": f"못 고치는 칸 {column}{_KEY}"}
if column not in info["editable"] or column not in base:
reason = info["locked"].get(column) or "이 줄에 없는 칸"
return 400, {"message": f"못 고치는 칸 {column}{reason}"}
problem = overrides.check_value(value)
if problem:
return 400, {"message": f"{column}: {problem}"}
overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"]))
return 200, public(next(r for r in rows(kind) if r["@id"] == row_id))
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"]))
meta = column_meta(each, item["column"])
item["column_label"] = meta["label"]
item["unit"] = meta["unit"]
out.append(item)
out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"]))
size = max(1, min(size, tables.MAX_PAGE_SIZE))
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 = ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw")
#: 조종원 식 글자 — B09 식 문자열(좌→우 차례)을 사람이 읽게만 바꿈
_WAGE_TEXT = OPERATOR_WAGE_FORMULA.replace("*", " × ").replace("/", " ÷ ")
_FORMULA = {
"hourly_loss_krw": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)",
"hourly_fuel_krw": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)",
"hourly_operator_krw": (
f"조종원 = 일 노임 × 인원 × {_WAGE_TEXT}"
" · 원 미만 절사 (건설품셈 8-1-2 · 건협 임금적용요령 4-나)"
),
"hourly_total_krw": "시간당 사용료 = 손료 + 연료 + 조종원",
}
_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜"
_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님"
_LOCKED = {
"machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음",
"machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"specification": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"annual_standard_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"fuel_type": _LOCKED_OPERATING,
"fuel_rate_l_per_hour": _LOCKED_OPERATING,
"misc_material_percent": _LOCKED_OPERATING,
"operator_occupation_code": _LOCKED_OPERATING,
"operator_person_per_day": _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_type": record.fuel_kind if record else None,
"fuel_rate_l_per_hour": _num(record.fuel_liters_per_hour) if record else None,
"misc_material_percent": _num(record.misc_material_percent) if record else None,
"operator_occupation_code": record.operator_occupation_code if record else None,
"operator_person_per_day": _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["hourly_loss_krw"] = _num(money.expense)
formula = {
"hourly_loss_krw": (
f"{_fmt(money.expense)} = {_fmt(spec.price_thousand_krw)}천원 × 1,000 × {spec.loss_coefficient_per_hour}"
)
}
if fuel_price is not None:
out["hourly_fuel_krw"] = _num(money.material)
misc = record.misc_material_percent if record else None
formula["hourly_fuel_krw"] = (
f"{_fmt(money.material)} = {_fmt(record.fuel_liters_per_hour)}"
+ (f" × (1 + {_fmt(misc)}%)" if misc is not None else "")
+ f" × {_fmt(fuel_price)}원/"
)
if wage is not None:
out["hourly_operator_krw"] = _num(money.labor)
persons = record.operator_person_days
formula["hourly_operator_krw"] = (
f"{_fmt(money.labor)} = {_fmt(out['operator_daily_wage_krw'])}"
+ (f" × {_fmt(persons)}" if persons is not None else "")
+ f" × {_WAGE_TEXT} (원 미만 절사)"
)
if cost.gaps:
out["hourly_note"] = " · ".join(cost.gaps)
else:
out["hourly_total_krw"] = _num(money.total)
out["hourly_note"] = None
formula["hourly_total_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,414 @@
"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1(2026-09-16 사용자 지시 · 브레인 승인).
사용자 잣대: 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.**
태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래
안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 ·
구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임)
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음.
⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건).
"""
from __future__ import annotations
from typing import Any
_ROW = dict[str, Any]
def _brackets(steps: list[float]) -> list[str]:
"""직고 구간 이름 — 원문 표기(`∼1.5 · ∼3 · ∼5 · ∼7 · 7이상`)를 그대로 씀."""
return [f"~{s:g}" for s in steps] + [f"{steps[-1]:g}이상"]
def _row(row_id: str, source: str, axis: tuple[str, ...], **cells: Any) -> _ROW:
return {"@id": row_id, "@source": source, "@axis": axis, **cells}
def _coef(doc: dict[str, Any], name: str) -> list[_ROW]:
"""토량환산계수 L·C — 품셈 체적변화율. 원문 표(`surcharge_*`)·공구손료율은 여기 아님."""
rows = []
for table in ("coef_soil_L", "coef_soil_C"):
for record in doc["variables"][table]["records"]:
rows.append(
_row(
f"{table}/{record['soil_type']}",
name,
("table", "soil_type"),
table=table,
soil_type=record["soil_type"],
min=record.get("min"),
max=record.get("max"),
rule=record.get("rule"),
selection=record.get("selection"),
)
)
return rows
def _material_surcharge(doc: dict[str, Any], name: str) -> list[_ROW]:
return [
_row(
f"rates_pct/{r['material']}",
name,
("material",),
material=r["material"],
rate=r.get("rate"),
condition=r.get("condition"),
alt_rate=r.get("alt_rate"),
alt_condition=r.get("alt_condition"),
pumsem=r.get("pumsem"),
)
for r in doc["rates_pct"]
]
def _formwork_reuse(doc: dict[str, Any], name: str) -> list[_ROW]:
rows = []
for r in doc["reuse_by_class"]:
rows.append(
_row(
f"reuse_by_class/{r['class']}",
name,
("table", "class"),
table="reuse_by_class",
**{"class": r["class"]},
reuse_count=r.get("reuse_count"),
examples=r.get("examples"),
)
)
ratios = doc["reuse_ratio_pct"]
for material in ("plywood", "timber"):
for count, percent in (ratios.get(material) or {}).items():
rows.append(
_row(
f"reuse_ratio_pct/{material}/{count}",
name,
("table", "material", "reuse_count"),
table="reuse_ratio_pct",
material=material,
reuse_count=int(count),
ratio_pct=percent,
)
)
for r in doc["euroform_type"]["classes"]:
rows.append(
_row(
f"euroform_type/classes/{r['key']}",
name,
("table", "class"),
table="euroform_type/classes",
**{"class": r["key"]},
daily_area_m2=r.get("daily_area_m2"),
examples=r.get("examples"),
)
)
return rows
def _rebar_complexity(doc: dict[str, Any], name: str) -> list[_ROW]:
rows = [
_row(
f"classes/{r['key']}",
name,
("table", "class"),
table="classes",
**{"class": r["key"]},
examples=r.get("examples"),
)
for r in doc["classes"]
]
for key, price in (doc["price_hint_krw_per_ton"].get("values") or {}).items():
rows.append(
_row(
f"price_hint_krw_per_ton/{key}",
name,
("table", "class"),
table="price_hint_krw_per_ton",
**{"class": key},
price_krw_per_ton=price,
)
)
return rows
def _timber_structure_class(doc: dict[str, Any], name: str) -> list[_ROW]:
return [
_row(
f"classes/{r['key']}",
name,
("class",),
**{"class": r["key"]},
carpenter=r.get("carpenter"),
laborer=r.get("laborer"),
examples=r.get("examples"),
)
for r in doc["classes"]
]
def _masonry_slope(doc: dict[str, Any], name: str) -> list[_ROW]:
brackets = _brackets(doc["steps_m"])
rows = []
for bond, faces in doc["table"].items():
for face, values in faces.items():
for bracket, ratio in zip(brackets, values):
rows.append(
_row(
f"table/{bond}/{face}/{bracket}",
name,
("bond", "face", "height_bracket"),
bond=bond,
face=face,
height_bracket=bracket,
face_slope_ratio=ratio,
)
)
return rows
def _masonry_back_length(doc: dict[str, Any], name: str) -> list[_ROW]:
brackets = _brackets(doc["steps_m"])
rows = []
for bond, pairs in doc["table_cm"].items():
for bracket, pair in zip(brackets, pairs):
rows.append(
_row(
f"table_cm/{bond}/{bracket}",
name,
("bond", "height_bracket"),
bond=bond,
height_bracket=bracket,
min_cm=pair[0],
max_cm=pair[1], # 원문 「-」 는 그대로 빈 칸
)
)
return rows
def _stone_kind(doc: dict[str, Any], name: str) -> list[_ROW]:
rows = []
def unit_rows(table: str, holder: dict[str, Any]) -> None:
for kind, lengths in holder.items():
if not isinstance(lengths, dict):
continue
for cm, value in lengths.items():
rows.append(
_row(
f"{table}/{kind}/{cm}",
name,
("table", "stone_kind", "back_length_cm"),
table=table,
stone_kind=kind,
back_length_cm=int(cm),
m3_per_m2=value,
)
)
for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
unit_rows(table, doc[table])
for kind, ratio in doc["backfill_ratio_of_back_length"].items():
if isinstance(ratio, (int, float)):
rows.append(
_row(
f"backfill_ratio_of_back_length/{kind}",
name,
("table", "stone_kind"),
table="backfill_ratio_of_back_length",
stone_kind=kind,
ratio=ratio,
)
)
fallback = doc["fallback"] # 돌 종류 미지정일 때 쓰는 한 벌(건설품셈 참고자료)
for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
for cm, value in (fallback.get(table) or {}).items():
rows.append(
_row(
f"fallback/{table}/{cm}",
name,
("table", "back_length_cm"),
table=f"fallback/{table}",
back_length_cm=int(cm),
m3_per_m2=value,
)
)
if isinstance(fallback.get("backfill_ratio_of_back_length"), (int, float)):
rows.append(
_row(
"fallback/backfill_ratio_of_back_length",
name,
("table",),
table="fallback/backfill_ratio_of_back_length",
ratio=fallback["backfill_ratio_of_back_length"],
)
)
return rows
def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]:
rows = [
_row(
f"back_length/classes/{r['key']}",
name,
("table", "class"),
table="back_length/classes",
**{"class": r["key"]},
max_cm=r.get("max_cm"),
)
for r in doc["back_length"]["classes"]
]
for value in doc["boulder_diameter"]["classes"]:
rows.append(
_row(
f"boulder_diameter/{value}",
name,
("table", "class"),
table="boulder_diameter",
**{"class": value},
)
)
for type_id, ratio in doc["face_slope"]["by_type"].items():
rows.append(
_row(
f"face_slope/{type_id}",
name,
("table", "type_id"),
table="face_slope",
type_id=type_id,
face_slope_ratio=ratio,
)
)
return rows
def _machine_productivity(doc: dict[str, Any], name: str) -> list[_ROW]:
"""기계 작업량 밑값 — 코드에 박혀 있던 값을 꺼낸 자리(불도저 속도·삽날 · 덤프 운반·적재 계수).
⚠ 식(`Q = n·q·f·E` 따위)은 값이 아니라 로직이라 계산 모듈에 남는다 — 여기는 계수만.
"""
rows = []
for r in doc["variables"]["dozer_speed"]["records"]:
rows.append(
_row(
f"dozer_speed/{r['track']}/{r['tonnage_ton']}/{r['gear']}",
name,
("table", "track", "tonnage_ton", "gear"),
table="dozer_speed",
track=r["track"],
tonnage_ton=float(r["tonnage_ton"]),
gear=int(r["gear"]),
forward_m_per_min=float(r["forward_m_per_min"]),
reverse_m_per_min=float(r["reverse_m_per_min"]),
)
)
for r in doc["variables"]["dozer_blade"]["records"]:
rows.append(
_row(
f"dozer_blade/{r['track']}/{r['tonnage_ton']}",
name,
("table", "track", "tonnage_ton"),
table="dozer_blade",
track=r["track"],
tonnage_ton=float(r["tonnage_ton"]),
blade_m3=float(r["blade_m3"]),
)
)
for r in doc["variables"]["dump_haul"]["records"]:
rows.append(
_row(
f"dump_haul/{r['key']}",
name,
("table", "key"),
table="dump_haul",
key=r["key"],
value=float(r["value"]),
unit=r.get("unit"),
meaning=r.get("meaning"),
)
)
for r in doc["variables"]["dump_material"]["records"]:
rows.append(
_row(
f"dump_material/{r['work_item_code']}",
name,
("table", "work_item_code"),
table="dump_material",
work_item_code=r["work_item_code"],
label=r["label"],
unit_weight_ton_per_m3=float(r["unit_weight_ton_per_m3"]),
loose_factor=float(r["loose_factor"]),
bucket_factor=float(r["bucket_factor"]),
loader_efficiency=float(r["loader_efficiency"]),
truck_efficiency=float(r["truck_efficiency"]),
loading_efficiency=float(r["loading_efficiency"]),
notes=r.get("notes"),
loading_note=r.get("loading_note"),
)
)
return rows
#: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인).
SPEC: dict[str, dict[str, Any]] = {
"coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]},
"material_surcharge": {
"file": "material_surcharge",
"build": _material_surcharge,
"editable": ["rate", "alt_rate"],
},
"formwork_reuse": {
"file": "formwork_reuse",
"build": _formwork_reuse,
"editable": ["reuse_count", "ratio_pct", "daily_area_m2"],
},
"rebar_complexity": {
# ⚠ 단가 참고값은 **B09 가 자재 단가로 셈한 값**이라 고칠 칸이 아님(코덱스 검증 1 · 2026-09-16).
# 사용자 잣대 「마스터 = 계산으로 나오는 값이 아니라 값 그 자체」와 어긋나 editable 에서 뺌.
"file": "rebar_complexity",
"build": _rebar_complexity,
"editable": [],
"locked": {
"price_krw_per_ton": "계산값 — B09 가 자재 단가로 셈(표시 전용 · 밑값을 고쳐야 바뀜)"
},
"formula": {
"price_krw_per_ton": "갈래별 이형철근 단가 참고값 = B09 가 자재 단가에서 셈 — 표시 전용(B08 계산에 안 듦)"
},
},
"timber_structure_class": {
"file": "timber_structure_class",
"build": _timber_structure_class,
"editable": ["carpenter", "laborer"],
},
"masonry_slope": {
"file": "masonry_slope",
"build": _masonry_slope,
"editable": ["face_slope_ratio"],
},
"masonry_back_length": {
"file": "masonry_back_length",
"build": _masonry_back_length,
"editable": ["min_cm", "max_cm"],
},
"stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]},
"machine_productivity": {
"file": "machine_productivity",
"build": _machine_productivity,
"editable": [
"forward_m_per_min",
"reverse_m_per_min",
"blade_m3",
"value",
"unit_weight_ton_per_m3",
"loose_factor",
"bucket_factor",
"loader_efficiency",
"truck_efficiency",
"loading_efficiency",
],
},
"masonry_class": {
"file": "masonry_class",
"build": _masonry_class,
"editable": ["max_cm", "face_slope_ratio"],
},
}
+202
View File
@@ -0,0 +1,202 @@
"""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"
#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격).
#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16).
MEANING_CHANGED = "meaning_changed"
STATE_ORDER = (MEANING_CHANGED, 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
#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌.
_JOIN = " · "
def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]:
"""(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘."""
stored = entry.get("meaning") or {}
keys = [k for k in stored if row is not None and k in row]
if not keys or all(row[k] == stored[k] for k in keys):
return "", ""
return (
_JOIN.join(str(stored[k]) for k in keys),
_JOIN.join(str(row[k]) for k in keys),
)
def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]:
original, value = entry.get("original"), entry.get("value")
was, now = meaning_line(entry, row)
if was:
state = MEANING_CHANGED
else:
state = EDITED if same(original, current) else SOURCE_CHANGED
out = {"original": original, "value": value, "state": state, "current": current}
if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감
out["was"], out["now"] = was, now
return out
def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**."""
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, row[column], row)
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,
meaning: dict[str, Any] | None = None,
) -> 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", ""),
# 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음
"meaning": meaning or {},
"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, marked = None, {"state": ORPHAN}
else:
current = row[column]
marked = mark(entry, current, row)
out.append(
{
"kind": kind,
"row_id": row_id,
"column": column,
"original": entry.get("original"),
"current": current,
"value": entry.get("value"),
"state": marked["state"],
**({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}),
}
)
return out
+98
View File
@@ -0,0 +1,98 @@
"""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·이름·줄 수)
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열)
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
"""
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")
def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"kinds": base_prices.kinds()}
@router.get("/base-prices/{kind}")
def get_base_prices(
kind: str,
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
q: str = "",
sort: str = "",
desc: int = 0,
) -> dict:
try:
return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc))
except base_prices.SortError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/base-prices/{kind}/{row_id:path}")
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)}
+224
View File
@@ -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)}
+245
View File
@@ -0,0 +1,245 @@
/* =============================================================================
* 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);
// 부동소수 끝자리 떠돌이(0.00018109999999999998)를 15자리에서 잘라 원래 수로 보임.
if (typeof value === "number") return String(Number(value.toPrecision(15)));
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;
}
/** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */
export interface TableSource {
source_id?: string;
/** 원본 이름 — 한 표가 원본 여럿을 모아 어느 것이 뒤처졌는지 가려야 함 */
name?: string;
publisher: string;
where: string;
cycle?: string;
latest_published?: string;
our_edition?: string;
checked_at?: string;
/** 뒤처짐 판정 — 서버만 함 · null 은 「모름」이라 띠를 안 세움 */
outdated?: boolean | null;
}
/**
* outdated . ( ).
* · .
*/
export function outdatedBanner(sources?: TableSource[]): string[] {
return (sources ?? [])
.filter((source) => source.outdated === true)
.map((source) => {
const head = source.name ? `${source.name}` : "";
const where = source.where ? ` ${source.where}` : "";
return `${head}이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher}${where}`;
});
}
/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */
export interface ServedKind {
group: string;
kind: string;
label: string;
rows: number;
}
/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */
export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] {
const boxes: { group: string; items: ServedKind[] }[] = [];
for (const kind of kinds) {
const box = boxes.find((each) => each.group === kind.group);
if (box) box.items.push(kind);
else boxes.push({ group: kind.group, items: [kind] });
}
return boxes;
}
/** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */
export interface SortOrder {
key: string;
desc: boolean;
}
/** 같은 열을 누르면 오름 → 내림 → 원본 차례, 다른 열이면 그 열 오름부터. */
export function nextSort(current: SortOrder | null, key: string): SortOrder | null {
if (current?.key !== key) return { key, desc: false };
return current.desc ? null : { key, desc: true };
}
/** 알림은 한 줄로도 목록으로도 옴 — 받은 대로 줄 목록으로. */
export function noticeLines(notice?: string | string[]): string[] {
if (!notice) return [];
return Array.isArray(notice) ? notice.filter(Boolean) : [notice];
}
/** 서버 글자의 `**굵게**` 를 조각으로 — 별표가 화면에 글자로 남으면 안 됨. */
export function boldSegments(text: string): { text: string; bold: boolean }[] {
return text
.split(/\*\*(.+?)\*\*/g)
.map((part, index) => ({ text: part, bold: index % 2 === 1 }))
.filter((part) => part.text !== "");
}
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
export interface TableMeta {
/** 한 표가 모은 원본마다 한 줄 */
source?: TableSource[];
/**
* ( · ).
* .
*/
notice?: string | string[];
/**
* .
* ( ) .
*/
sortable?: string[];
editable?: string[];
/** 열key → 왜 못 고치나 */
locked?: Record<string, string>;
/** 열key → 식 자체 */
formula?: Record<string, string>;
}
/**
* · (2026-09-15~16 ).
* meaning_changed = ( ) .
*/
export type OverrideState = "edited" | "source_changed" | "meaning_changed" | "orphan";
export interface CellInfo {
editable: boolean;
reason: string | null;
overridden: boolean;
/** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */
original?: unknown;
/** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */
state: OverrideState | "missing" | null;
/** source_changed·meaning_changed 칸만 — 지금 원본 값 */
current?: unknown;
/** meaning_changed 칸만 — 고칠 때 줄 이름 */
was?: string;
/** meaning_changed 칸만 — 지금 줄 이름 */
now?: string;
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; was?: string; now?: string }
>
| 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" || state === "meaning_changed"
? { current: override?.current }
: {}),
...(override?.was !== undefined ? { was: override.was } : {}),
...(override?.now !== undefined ? { now: override.now } : {}),
rule: meta.formula?.[key] ?? null,
filled: filled?.[key] ?? null,
};
}
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;
}
};
}
+58
View File
@@ -0,0 +1,58 @@
/* =============================================================================
* 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 { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch";
import type { ServedKind } from "./Z01_MasterData_UI_Cells";
import { buildRowsView } from "./Z01_MasterData_UI_Rows";
import { buildSidePanel } from "./Z01_MasterData_UI_Side";
import "./Z01_MasterData_UI_Style.css";
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[] = [];
let kinds: ServedKind[] = [];
try {
[groups, kinds] = await Promise.all([fetchMasterTree(), fetchBaseKinds()]);
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
root.innerHTML = "";
root.append(buildPage(groups, kinds));
}
function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement {
const view = buildRowsView();
const layout = el("div", { className: "ui-workflow-layout z01-master" });
const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] });
const overlays = createWorkflowOverlays({
title: L("Z01_MasterData_Title"),
optionsContent: buildSidePanel(groups, kinds, 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;
}
+447
View File
@@ -0,0 +1,447 @@
/* =============================================================================
* Z01_MasterData_UI_Rows.ts
* ( · ) · ,
*
* , `save` editable .
* · · ·
* (2026-09-15 ).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
import {
boldSegments,
cellInfo,
clampPage,
columnTitle,
latestOnly,
nextSort,
noticeLines,
outdatedBanner,
pageCount,
parseCellInput,
rowCells,
shownColumns,
valueWithUnit,
type CellInfo,
type MasterColumn,
type SortOrder,
} 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;
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */
searchable?: boolean;
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);
}
/**
* · ( ·· ).
* . .
*/
function meaningNames(info: CellInfo): string {
if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing");
return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`;
}
/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */
function stateText(info: CellInfo, column: MasterColumn): string {
const original = valueWithUnit(info.original, column.unit);
switch (info.state) {
case "meaning_changed":
// 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히.
// ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김.
return `${L("Z01_MasterData_State_MeaningChanged")}${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`;
case "source_changed":
return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`;
case "orphan":
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: "", sort: null as SortOrder | null };
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("div", { className: "z01-master__notice" });
const banner = el("div", { className: "z01-master__outdated" });
const detail = el("div", { className: "z01-master__detail" });
const grid = el("div", { className: "z01-master__grid-wrap" });
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,
banner,
toolbar,
detail,
grid,
pager,
],
});
toolbar.hidden = true;
pager.hidden = true;
detail.hidden = true;
notice.hidden = true;
banner.hidden = true;
function goTo(page: number): void {
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
state.page = page === Infinity ? pages : clampPage(page, pages);
void load();
}
async function load(): Promise<void> {
if (!source) return;
const asked = state.sort;
grid.classList.add("is-loading");
let data: MasterRows | undefined;
try {
data = await fetchLatest(source, {
page: state.page,
size: PAGE_SIZE,
q: state.q,
sort: state.sort,
});
} catch (error) {
grid.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
// 서버가 그 열로는 못 세운다고 하면(400) 누른 표시를 지움 — 눌렀는데 안 바뀐 것처럼 두지 않음.
if (asked && state.sort === asked) {
state.sort = null;
draw();
}
return;
}
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
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 }));
}
// 차례 — 누르기 전에는 아무 표시도 없음(원본 차례라는 것이 보이게 · 2026-09-16 브레인).
if (meta.sortable?.includes(column.key)) {
th.classList.add("is-sortable");
if (state.sort?.key === column.key) {
th.append(el("span", { className: "z01-master__sort", text: state.sort.desc ? "▼" : "▲" }));
}
th.addEventListener("click", () => {
state.sort = nextSort(state.sort, column.key);
state.page = 1;
void load();
});
}
return th;
}
function draw(): void {
if (!last) return;
const meta = last;
// 표 머리 — 모두 서버 글자 그대로(화면이 사전을 가지면 두 벌이 됨).
// ① 알림(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지) ② 뒤처진 원본(서버 outdated 만)
const notices = noticeLines(meta.notice);
notice.replaceChildren(
...notices.map((text) =>
el("p", {
children: boldSegments(text).map((part) =>
part.bold ? el("strong", { text: part.text }) : el("span", { text: part.text }),
),
}),
),
);
notice.hidden = notices.length === 0;
const outdated = outdatedBanner(meta.source);
banner.replaceChildren(...outdated.map((text) => el("p", { text: `${text}` })));
banner.hidden = outdated.length === 0;
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.replaceAll("_", "-")}`);
if (info.state !== "edited") {
// 뜻 바뀜은 값 어긋남보다 나쁨 — 표시부터 더 세게(2026-09-16 브레인).
td.textContent = `${info.state === "meaning_changed" ? "⛔" : "⚠"} ${texts[i]}`;
tr.classList.add(info.state === "meaning_changed" ? "has-danger" : "has-warning");
}
td.title = stateText(info, column);
}
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 === "meaning_changed"
? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit))
: info.state === "source_changed"
? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit))
: info.state === "orphan"
? null
: fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit));
if (target && source?.save) {
lines.push(
createButton({
label: target,
variant: info.state === "meaning_changed" ? "danger" : "ghost",
onClick: () => void drop(index, column, info),
}),
);
}
}
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));
}
/** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */
async function drop(index: number, column: MasterColumn, info: CellInfo): Promise<void> {
if (info.state === "meaning_changed") {
const ok = await showConfirmDialog(
`${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`,
L("Z01_MasterData_Edit_DropConfirm"),
);
if (!ok) return;
}
await commit(index, column, null);
}
/** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */
async function commit(index: number, column: MasterColumn, value: unknown): Promise<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;
state.sort = null;
title.textContent = picked.title;
titleKey.textContent = picked.key;
notice.hidden = true;
banner.hidden = true;
search.root.hidden = picked.searchable === false;
toolbar.hidden = false;
pager.hidden = false;
detail.hidden = true;
last = null;
selected = null;
grid.replaceChildren();
void load();
}
return { root, show };
}
+202
View File
@@ -0,0 +1,202 @@
/* =============================================================================
* 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 {
fetchBasePrices,
fetchMasterRows,
fetchOverrides,
type MasterGroup,
type MasterRows,
type RowsQuery,
saveBasePrice,
VALUES_TABLE_ID,
} from "./Z01_MasterData_Api_Fetch";
import {
groupKinds,
valueWithUnit,
type OverrideState,
type ServedKind,
} from "./Z01_MasterData_UI_Cells";
import type { RowsView } from "./Z01_MasterData_UI_Rows";
export function buildSidePanel(
groups: MasterGroup[],
kinds: ServedKind[],
view: RowsView,
): HTMLElement {
const panel = el("div", { className: "z01-master__side" });
const activate = (button: HTMLElement): void => {
panel.querySelector(".is-active")?.classList.remove("is-active");
button.classList.add("is-active");
};
// 갈래·kind·이름·줄 수 모두 서버 것 — 화면은 목록도 이름도 안 가짐(kind 가 늘면 저절로 섬).
const boxes = groupKinds(kinds).map((box) => {
const grid = el("div", { className: "z01-master__base-grid" });
for (const served of box.items) {
const button = el("button", {
className: "z01-master__base",
attrs: { type: "button" },
children: [
el("span", { text: served.label }),
el("span", {
className: "z01-master__key",
text: served.rows.toLocaleString("ko-KR"),
}),
],
});
button.addEventListener("click", () => {
activate(button);
view.show({
title: `${box.group} ${served.label}`,
key: `base-prices/${served.kind}`,
load: (query) => fetchBasePrices(served.kind, query),
save: (rowId, values) => saveBasePrice(served.kind, rowId, values),
});
});
grid.append(button);
}
return { group: box.group, grid };
});
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: L("Z01_MasterData_Overrides"),
key: "overrides",
searchable: false,
// 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임.
load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))),
});
});
boxes[0]?.grid.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(
...boxes.map((box) => section(box.group, box.grid)),
section(L("Z01_MasterData_Tree"), tree),
);
attachCollapsible(panel);
return panel;
}
const STATE_LABEL: Record<OverrideState, Parameters<typeof L>[0]> = {
meaning_changed: "Z01_MasterData_List_MeaningChanged",
source_changed: "Z01_MasterData_List_SourceChanged",
orphan: "Z01_MasterData_List_Orphan",
edited: "Z01_MasterData_List_Edited",
};
/**
* ( ) .
* (2026-09-15 ). .
*/
async function loadOverrides(
query: RowsQuery,
kindLabels: Map<string, string>,
): Promise<MasterRows> {
const { items, total } = await fetchOverrides(query);
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_List_Kind"),
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.map((item) => ({
...item,
value: valueWithUnit(item.value, item.unit),
original: valueWithUnit(item.original, item.unit),
...(Object.hasOwn(item, "current")
? { current: valueWithUnit(item.current, item.unit) }
: {}),
// state 가 안 오면 때우지 않고 드러냄(서버 버그).
state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"),
kind: kindLabels.get(item.kind) ?? item.kind,
})),
total,
};
}
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] });
}
+317
View File
@@ -0,0 +1,317 @@
/* 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__outdated p {
margin: 0;
}
.z01-master__outdated {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-canvas));
color: var(--color-danger);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
}
/* 표 머리 알림 — 고친 값이 닿는 자리·법정값 경고. 흐리면 「저장이 안 됐다」로 읽힘(2026-09-16 브레인) */
.z01-master__notice {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-accent);
background: var(--color-mist-violet);
color: var(--color-accent);
font-size: var(--text-body-sm);
}
.z01-master__notice p {
margin: 0;
}
.z01-master__toolbar {
display: flex;
flex-wrap: wrap;
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__grid th.is-sortable {
cursor: pointer;
}
.z01-master__sort {
margin-left: var(--spacing-4);
color: var(--color-accent);
}
.z01-master__mark {
margin-left: var(--spacing-4);
cursor: help;
}
.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,
.z01-master__grid tr.has-danger td:first-child {
box-shadow: inset 4px 0 0 var(--color-danger);
}
/* 뜻 바뀜 — 값 어긋남보다 나쁨(같은 번호에 딴 물건). 줄째 더 진하게. */
.z01-master__grid tr.has-danger td {
background: color-mix(in srgb, var(--color-danger) 18%, var(--color-canvas));
}
.z01-master__grid tr td.is-meaning-changed {
background: var(--color-danger);
color: var(--color-primary-text);
font-weight: var(--font-weight-bold);
}
/* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */
.z01-master__grid tr td.is-source-changed,
.z01-master__grid tr td.is-orphan,
.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;
}
+3
View File
@@ -116,6 +116,8 @@ export const ROUTES = {
B11_STATUS: "b11-status",
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
B11_LOADING: "b11-loading",
// 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01).
Z01_MASTER_DATA: "z01-master-data",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -138,6 +140,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
ROUTES.Z01_MASTER_DATA,
];
/* -----------------------------------------------------------------------------
+5
View File
@@ -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 (
router as b09_material_prices_router,
)
from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
require_project_access,
require_system_admin,
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_factors_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)
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값 공시를 채택**하는 규칙(2026-09-16 브레인).
우리 고르기 규칙이 물품코드마다 가장 나중 공시, 값이 실린 공시(`prce = 0`)
제값을 덮었다. 각형강관 25*25*1 `1,460 1,500 0` 으로 끝난 것이 그것이다.
가격이 0 아니라 ** 공시에 값이 실린 **이라, 0 값으로 받으면 된다.
무엇을 고치나
- 마스터의 0 줄만 고친다. **원천 스냅숏은 건드린다**(읽기만).
- 0 아닌 줄은 글자도 건드린다 규칙을 통째로 다시 돌리지 않는다.
- 고친 줄마다 **사유**(`price_adopted_reason`) 남긴다. 조용히 바꾸지 않는다.
- 공시 날짜·번호도 **채택한 공시의 **으로 바꾼다 어느 값인지 화면에서 보여야 한다.
- 공시에도 값이 없어 0 남는 줄은 `zero_price_unrecovered` 낸다(규칙으로 고침).
같은 날짜에 0 제값이 함께 실린다(2025-04-24) 날짜만 보지 말고 **0 건너뛴 **
가장 나중 공시를 고른다.
돌리기: `./venv/Scripts/python.exe resources/data_cost_input_value/_build_mat_price.py`
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json"
MASTER = ROOT / "resources/data_cost_input_value/mat_price_public_2026-08-14.json"
#: 값이 안 실린 공시를 가리는 잣대 — 빈 칸·0 은 「값 없음」이다.
EMPTY_PRICES = {"", "0", "0.0", "0.00"}
SELECTION_POLICY = (
"latest notice per product identification number within the local snapshot, "
"skipping notices whose price is empty or zero "
"(a zero price means the notice carried no price, not a price of zero)"
)
UNRECOVERED_NOTE = (
"앞 공시에도 값이 없어 0 이 남은 줄. **규칙으로 못 고친다** — 0 을 값으로 쓰지 말고 "
"그 자재가 실제로 필요해지면 원천을 다시 받거나 견적을 받아야 한다."
)
def price_of(row: dict) -> str:
return str(row.get("prce", "")).strip()
def has_price(row: dict) -> bool:
return price_of(row) not in EMPTY_PRICES
def notice_day(row: dict) -> str:
return str(row.get("nticeDt", ""))[:10]
def build() -> dict:
raw = json.loads(RAW.read_text(encoding="utf-8"))
master = json.loads(MASTER.read_text(encoding="utf-8"))
records = master["variables"]["mat_price"]["records"]
history: dict[str, list[dict]] = {}
for row in raw:
history.setdefault(str(row.get("prdctIdntNo")), []).append(row)
for rows in history.values():
rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True)
repaired, unrecovered = 0, []
for record in records:
if str(record.get("price_krw")).strip() not in EMPTY_PRICES:
continue
was = notice_day(record | {"nticeDt": record.get("notice_datetime")})
adopted = next((r for r in history.get(str(record["item_code"]), ()) if has_price(r)), None)
if adopted is None:
unrecovered.append(
{
"item_code": record["item_code"],
"classification_name": record.get("classification_name", ""),
"specification": record.get("specification", ""),
"notice_datetime": record.get("notice_datetime", ""),
"reason": "이 물품코드의 모든 공시에 값이 없음",
}
)
continue
record["price_krw"] = int(price_of(adopted))
record["notice_datetime"] = str(adopted.get("nticeDt", ""))
record["notice_number"] = str(adopted.get("prceNticeNo", ""))
record["price_adopted_reason"] = f"{was} 공시에 값이 없어 {notice_day(adopted)} 값을 씀"
repaired += 1
master["selection_policy"] = SELECTION_POLICY
master["zero_price_unrecovered"] = {"note": UNRECOVERED_NOTE, "items": unrecovered}
return {"master": master, "repaired": repaired, "unrecovered": len(unrecovered)}
if __name__ == "__main__":
result = build()
MASTER.write_text(
json.dumps(result["master"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}")
@@ -13,8 +13,8 @@
"file": "coef_2026.json",
"dataset_id": "coef",
"effective_date": "2026-01-01",
"sha256": "0bc3fe981165e8d320b7c20f4535c545f2f54415427ac60205dac4a72dbb4f19",
"size_bytes": 29413
"sha256": "daba170cd806374c92e086dec69f9559271c0317a7426e66e9dc841c2ca264bd",
"size_bytes": 31715
},
{
"file": "fx_2026-08-14.json",
@@ -48,8 +48,8 @@
"file": "mat_price_public_2026-08-14.json",
"dataset_id": "mat_price_public",
"effective_date": "2026-08-14",
"sha256": "4f5f76cf5b39ee4e3152fec11b9f5efc70ecea075a66a5cc1bda6ff7691de5b7",
"size_bytes": 4741651
"sha256": "75ddfe85b0140d782cd00187ec459e995cd0db9982e7bd48f62f83cd65248acc",
"size_bytes": 4748976
},
{
"file": "oil_2026-08-14.json",
@@ -787,6 +787,7 @@
]
},
"surcharge_mat": {
"used_value_at": "resources/data_material_surcharge/material_surcharge_2026-01-01.json → rates_pct — ⚠ 코드가 읽어 쓰는 값은 저기다. 이 표는 원문 보관용이라 읽는 코드가 없다(두 벌이 아니다).",
"unit": "percent",
"representation": "full_source_tables",
"duplicate_application_prohibited": true,
@@ -1108,6 +1109,8 @@
"max": 1.45,
"duplicate_group": "암괴나 호박돌이 섞인 점토",
"duplicate_index": 1,
"duplicate_same_value": true,
"duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).",
"selection": "pending"
},
{
@@ -1129,6 +1132,9 @@
"max": 1.45,
"duplicate_group": "암괴나 호박돌이 섞인 점토",
"duplicate_index": 2,
"duplicate_same_value": true,
"hidden_duplicate": true,
"duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).",
"selection": "pending"
}
]
@@ -1210,6 +1216,8 @@
"max": 0.95,
"duplicate_group": "암괴나 호박돌이 섞인 점토",
"duplicate_index": 1,
"duplicate_same_value": true,
"duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).",
"selection": "pending"
},
{
@@ -1231,6 +1239,9 @@
"max": 0.95,
"duplicate_group": "암괴나 호박돌이 섞인 점토",
"duplicate_index": 2,
"duplicate_same_value": true,
"hidden_duplicate": true,
"duplicate_reason": "원문 두 줄 · 값 같음 — 화면에는 한 줄로 보임. 원문은 그대로 둠(임의 보정 없음).",
"selection": "pending"
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
{
"schema_version": "1.0",
"dataset_id": "machine_productivity",
"effective_date": "2026-01-01",
"note": "기계 작업량 밑값 — 코드에 박혀 있던 값을 그대로 옮긴 벌(2026-09-16 사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」). 값은 한 칸도 안 바꿨다 — 옮기기 전후 금액이 같다.",
"policy": {
"no_value_changed": "코드에 있던 값을 그대로 옮겼다. 원문과의 대조는 시험이 지킨다.",
"formula_stays_in_code": "식(Q = n·q·f·E 따위)은 값이 아니라 로직이라 코드에 둔다 — 계수만 여기 있다.",
"source_tables_mashed": "삽날 용량표는 원문 옮기기에서 절 이름이 뭉개져(`5-3.2-5.5-타 이 어…`) 표로 안 들어왔다. 값은 원문 본문 것이고, 원문 표가 제 모양으로 들어오면 그때 대조한다."
},
"sources": [
{
"doc": "건설공사 표준품셈 8-2-1 불도저 2.가·나 전진·후진 속도",
"tables": [
"C0426",
"C0427"
],
"role": "primary"
},
{
"doc": "건설공사 표준품셈 8-2-1 1.가 삽날 용량 q˚",
"tables": [],
"role": "primary",
"note": "원문 표가 뭉개져 카탈로그에 표로 없음"
},
{
"doc": "산림사업 표준품셈(고시 제2025-82호) 10-12 「1. 적재」·「2. 운반」 식 서식",
"tables": [],
"role": "primary",
"note": "원문이 표가 아니라 채워 넣는 식이라 공종 마스터에 안 실림"
}
],
"variables": {
"dozer_speed": {
"unit": "m/분",
"key": "track+tonnage_ton+gear",
"note": "후진이 「-」인 단은 원문에 값이 없어 담지 않는다(없는 속도로 싸이클이 서면 값이 조용히 틀림).",
"records": [
{
"track": "무한궤도",
"tonnage_ton": "4",
"gear": 1,
"forward_m_per_min": "40",
"reverse_m_per_min": "63"
},
{
"track": "무한궤도",
"tonnage_ton": "4",
"gear": 2,
"forward_m_per_min": "57",
"reverse_m_per_min": "85"
},
{
"track": "무한궤도",
"tonnage_ton": "7",
"gear": 1,
"forward_m_per_min": "43",
"reverse_m_per_min": "53"
},
{
"track": "무한궤도",
"tonnage_ton": "7",
"gear": 2,
"forward_m_per_min": "67",
"reverse_m_per_min": "78"
},
{
"track": "무한궤도",
"tonnage_ton": "7",
"gear": 3,
"forward_m_per_min": "92",
"reverse_m_per_min": "107"
},
{
"track": "무한궤도",
"tonnage_ton": "10",
"gear": 1,
"forward_m_per_min": "42",
"reverse_m_per_min": "50"
},
{
"track": "무한궤도",
"tonnage_ton": "10",
"gear": 2,
"forward_m_per_min": "64",
"reverse_m_per_min": "75"
},
{
"track": "무한궤도",
"tonnage_ton": "10",
"gear": 3,
"forward_m_per_min": "88",
"reverse_m_per_min": "105"
},
{
"track": "무한궤도",
"tonnage_ton": "12",
"gear": 1,
"forward_m_per_min": "40",
"reverse_m_per_min": "48"
},
{
"track": "무한궤도",
"tonnage_ton": "12",
"gear": 2,
"forward_m_per_min": "55",
"reverse_m_per_min": "70"
},
{
"track": "무한궤도",
"tonnage_ton": "12",
"gear": 3,
"forward_m_per_min": "75",
"reverse_m_per_min": "100"
},
{
"track": "무한궤도",
"tonnage_ton": "13",
"gear": 1,
"forward_m_per_min": "40",
"reverse_m_per_min": "48"
},
{
"track": "무한궤도",
"tonnage_ton": "13",
"gear": 2,
"forward_m_per_min": "55",
"reverse_m_per_min": "70"
},
{
"track": "무한궤도",
"tonnage_ton": "19",
"gear": 1,
"forward_m_per_min": "40",
"reverse_m_per_min": "46"
},
{
"track": "무한궤도",
"tonnage_ton": "19",
"gear": 2,
"forward_m_per_min": "55",
"reverse_m_per_min": "70"
},
{
"track": "무한궤도",
"tonnage_ton": "19",
"gear": 3,
"forward_m_per_min": "75",
"reverse_m_per_min": "98"
},
{
"track": "무한궤도",
"tonnage_ton": "32",
"gear": 1,
"forward_m_per_min": "40",
"reverse_m_per_min": "43"
},
{
"track": "무한궤도",
"tonnage_ton": "32",
"gear": 2,
"forward_m_per_min": "52",
"reverse_m_per_min": "58"
},
{
"track": "무한궤도",
"tonnage_ton": "32",
"gear": 3,
"forward_m_per_min": "70",
"reverse_m_per_min": "78"
},
{
"track": "타이어",
"tonnage_ton": "15",
"gear": 1,
"forward_m_per_min": "83",
"reverse_m_per_min": "92"
},
{
"track": "타이어",
"tonnage_ton": "15",
"gear": 2,
"forward_m_per_min": "200",
"reverse_m_per_min": "125"
},
{
"track": "타이어",
"tonnage_ton": "28",
"gear": 1,
"forward_m_per_min": "92",
"reverse_m_per_min": "92"
},
{
"track": "타이어",
"tonnage_ton": "28",
"gear": 2,
"forward_m_per_min": "200",
"reverse_m_per_min": "200"
},
{
"track": "타이어",
"tonnage_ton": "33",
"gear": 1,
"forward_m_per_min": "92",
"reverse_m_per_min": "110"
},
{
"track": "타이어",
"tonnage_ton": "33",
"gear": 2,
"forward_m_per_min": "210",
"reverse_m_per_min": "250"
}
]
},
"dozer_blade": {
"unit": "㎥",
"key": "track+tonnage_ton",
"note": "무한궤도 10·13 톤이 둘 다 1.5 ㎥ 라 용량만으로는 못 가른다 — 속도로 마저 가른다.",
"records": [
{
"track": "무한궤도",
"tonnage_ton": "4",
"blade_m3": "0.5"
},
{
"track": "무한궤도",
"tonnage_ton": "7",
"blade_m3": "1.1"
},
{
"track": "무한궤도",
"tonnage_ton": "10",
"blade_m3": "1.5"
},
{
"track": "무한궤도",
"tonnage_ton": "12",
"blade_m3": "2.0"
},
{
"track": "무한궤도",
"tonnage_ton": "13",
"blade_m3": "1.5"
},
{
"track": "무한궤도",
"tonnage_ton": "19",
"blade_m3": "3.2"
},
{
"track": "무한궤도",
"tonnage_ton": "32",
"blade_m3": "5.5"
},
{
"track": "타이어",
"tonnage_ton": "15",
"blade_m3": "3.1"
},
{
"track": "타이어",
"tonnage_ton": "28",
"blade_m3": "4.0"
},
{
"track": "타이어",
"tonnage_ton": "33",
"blade_m3": "5.7"
}
]
},
"dump_haul": {
"key": "key",
"note": "덤프 운반·적재 식이 쓰는 밑값. 장비 규격이 바뀌면 여기를 고친다.",
"records": [
{
"key": "truck_ton",
"value": "15",
"unit": "ton",
"meaning": "덤프트럭 적재 규격 T"
},
{
"key": "bucket_m3",
"value": "0.7",
"unit": "㎥",
"meaning": "적재기계 버켓 용량 q"
},
{
"key": "loader_cycle_sec",
"value": "20",
"unit": "초",
"meaning": "적재 대기 싸이클 ㎝s"
},
{
"key": "speed_loaded_kmh",
"value": "5",
"unit": "㎞/hr",
"meaning": "적재 주행속도 V1"
},
{
"key": "speed_empty_kmh",
"value": "6",
"unit": "㎞/hr",
"meaning": "공차 주행속도 V2"
},
{
"key": "unload_min",
"value": "1.1",
"unit": "분",
"meaning": "적하 시간 t3"
},
{
"key": "wait_min",
"value": "0.9",
"unit": "분",
"meaning": "대기 시간 t4"
},
{
"key": "cover_min",
"value": "0.5",
"unit": "분",
"meaning": "덮개 시간 t5"
},
{
"key": "loading_cycle_sec",
"value": "22",
"unit": "초",
"meaning": "적재 식 싸이클 ㎝(180°)"
}
]
},
"dump_material": {
"key": "work_item_code",
"note": "10-12 절 재료별 값 — 고른 값·버린 값 사유를 줄에 함께 둔다. 적재 사유는 `loading_note` 로 따로 둔다(운반 줄 사유와 섞지 않음).",
"records": [
{
"work_item_code": "FP-10-12-01",
"label": "토사",
"unit_weight_ton_per_m3": "1.9",
"loose_factor": "1.3",
"bucket_factor": "0.9",
"loader_efficiency": "0.85",
"truck_efficiency": "0.9",
"loading_efficiency": "0.60",
"notes": [
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)",
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
],
"loading_note": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림"
},
{
"work_item_code": "FP-10-12-02",
"label": "암절취",
"unit_weight_ton_per_m3": "2.4",
"loose_factor": "1.35",
"bucket_factor": "0.55",
"loader_efficiency": "0.35",
"truck_efficiency": "0.9",
"loading_efficiency": "0.35",
"notes": [
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림) · 원문 10 이면 n 1.18배",
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
],
"loading_note": "E0 = 0.35(본문·표 「파쇄암」 같음)"
},
{
"work_item_code": "FP-10-12-03",
"label": "발파암",
"unit_weight_ton_per_m3": "2.4",
"loose_factor": "1.625",
"bucket_factor": "0.55",
"loader_efficiency": "0.35",
"truck_efficiency": "0.9",
"loading_efficiency": "0.35",
"notes": [
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)",
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)",
"E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"
],
"loading_note": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)"
}
]
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
{
"schema_version": "1.0",
"dataset_id": "data_master_sources",
"effective_date": "2026-09-15",
"generated_at": "2026-09-16T14:05:40+09:00",
"note": "기초단가 자료가 **어디서 오고 얼마 만에 바뀌는지** 적은 표. 파일 안에는 제 판만 있고 「최신이 무엇인지·어디서 받는지」가 없어 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). 그 자리를 메운다.",
"policy": {
"values_untouched": "값을 담지 않는다. 자료가 어디서 오는지만 담는다.",
"our_edition_is_read": "`our_edition` 은 **실제 파일에서 읽는다**(`effective_date`, 파생본은 `derived_from.effective_date`). 손으로 적으면 갱신하고 안 고쳐 또 뒤처진다.",
"address_found_on": "받는 자리 일곱은 2026-09-16 에 안티그래비티가 찾아 채웠다(제조노임·기계·기계 운전경비·자재·유가 전국·유가 지역·요율). 건설노임 한 줄은 그 전(2026-09-15)에 브레인이 준 것이다.",
"no_invented_address": "받는 자리는 **받은 것만** 적는다. 모르면 빈 칸으로 두고 사유를 단다 — 주소를 지어내지 않는다.",
"status_rule": "`latest_published` 가 `our_edition` 보다 뒤면 **뒤처짐**, 같으면 최신, 둘 가운데 하나라도 없으면 **모름**. 날마다 바뀌는 유가는 날짜 차이로 보여야 한다."
},
"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": "중소기업중앙회 https://www.kbiz.or.kr | [정보마당] > [조사연구통계](게시판 mnSeq=325) > 「중소제조업 직종별 임금조사보고서」 PDF",
"cycle": "반기",
"latest_published": "",
"latest_basis": "",
"our_edition": "2026-07-01",
"our_edition_from": "effective_date",
"status": "unknown",
"checked_on": "2026-09-16",
"note": "우리 판은 2026 상반기 조사(조사월 2026-03 · 공표 2026-06-30)다. ⚠ 게시판에 반기마다 새 보고서가 올라오므로 **최신 공표일은 게시판에서 봐야** 한다.",
"files": [
"resources/data_cost_input_value/labor_mfg_2026-07-01.json"
],
"latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다."
},
{
"source_id": "mach_base",
"kind": "machine",
"kind_ko": "기계",
"name_ko": "건설공사 표준품셈 제8장 건설기계",
"publisher": "국토교통부",
"where_to_get": "공사비원가관리센터 https://cost.kict.re.kr | [표준품셈] (또는 CODIL https://www.codil.or.kr [건설기준정보] > [건설공사표준품셈]) > 건설공사 표준품셈 원문 PDF(공통부문 제8장 건설기계 8-3)",
"cycle": "해마다",
"latest_published": "2026-01-01",
"latest_basis": "2026-09-15 브레인 쪽지",
"our_edition": "2026-01-01",
"our_edition_from": "effective_date",
"status": "current",
"checked_on": "2026-09-16",
"note": "우리 판이 최신이다. 취득가·손료계수·연료소모량·조종원이 다 여기서 온다.",
"files": [
"resources/data_cost_input_value/mach_base_2026.json"
]
},
{
"source_id": "machine_operating",
"kind": "machine",
"kind_ko": "기계",
"name_ko": "건설기계 운전경비(품셈 8-4 파생본)",
"publisher": "국토교통부",
"where_to_get": "공사비원가관리센터 https://cost.kict.re.kr | 별도 배포처 없음(위 건설공사 표준품셈 원문과 같음) > 공통부문 제8장 건설기계 「8-4 건설기계 운전경비 산정」 표",
"cycle": "해마다 — 위 품셈을 따라간다",
"latest_published": "2026-01-01",
"latest_basis": "파생 원본(mach_base)의 판",
"our_edition": "2026-01-01",
"our_edition_from": "derived_from.effective_date",
"status": "current",
"checked_on": "2026-09-16",
"note": "⚠ **판을 모르는 파일이 아니다** — 파일 안 `derived_from.effective_date` 에 2026-01-01 이 박혀 있다(건설품셈 8-4 운전경비 파생본 · 원본 지문까지 함께). `mach_fuel_rate`·`mach_operator_map` 이 다 차면 걷어낼 자리다.",
"files": [
"resources/data_cost_machine_operating/machine_operating_2026.json"
]
},
{
"source_id": "mat_price_public",
"kind": "material",
"kind_ko": "자재",
"name_ko": "관급자재 단가(시설공통자재)",
"publisher": "조달청",
"where_to_get": "나라장터 https://www.g2b.go.kr | [가격정보] > [시설공통자재] (또는 조달청 https://www.pps.go.kr [정보제공] > [업무별자료] > [시설공사]) > 시설공통자재 가격조사 엑셀",
"cycle": "반기 + 수시",
"latest_published": "",
"latest_basis": "",
"our_edition": "2026-08-14",
"our_edition_from": "effective_date",
"status": "unknown",
"checked_on": "2026-09-16",
"note": "수시 공고라 「최신 공표일」이 한 날로 안 정해진다 — 받은 날(우리 판)이 곧 기준이다. 철근·레미콘·아스콘은 이 벌에 없다(쇼핑몰 스냅숏 미확보).",
"files": [
"resources/data_cost_input_value/mat_price_public_2026-08-14.json"
],
"latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다."
},
{
"source_id": "oil",
"kind": "oil",
"kind_ko": "유가",
"name_ko": "유가 — 전국평균",
"publisher": "한국석유공사(오피넷)",
"where_to_get": "오피넷 https://www.opinet.co.kr | [국내유가통계] > [주유소] > [평균판매가격] — 「전국」 탭 조회 뒤 [엑셀저장](API avgAllPrice.do)",
"cycle": "주간 · 일별",
"latest_published": "",
"latest_basis": "",
"our_edition": "2026-08-14",
"our_edition_from": "effective_date",
"status": "unknown",
"checked_on": "2026-09-16",
"note": "날마다 바뀌는 값이라 「최신」이 곧 오늘이다. **뒤처짐을 날짜 차이로 보여야 하는 자료**다. ⚠ 시도별 판과 「전국」 줄이 겹치는데 값도 기준일도 다르다(어느 쪽을 쓸지 판정 대기).",
"files": [
"resources/data_cost_input_value/oil_2026-08-14.json"
],
"latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다."
},
{
"source_id": "oil_regional",
"kind": "oil",
"kind_ko": "유가",
"name_ko": "유가 — 시도별",
"publisher": "한국석유공사(오피넷)",
"where_to_get": "오피넷 https://www.opinet.co.kr | [국내유가통계] > [주유소] > [평균판매가격] — 「지역별」 탭에서 시도를 고른 뒤 [엑셀저장](API avgSidoPrice.do)",
"cycle": "주간 · 일별",
"latest_published": "",
"latest_basis": "",
"our_edition": "2026-09-09",
"our_edition_from": "effective_date",
"status": "unknown",
"checked_on": "2026-09-16",
"note": "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」를 따르려고 둔 벌이다.",
"files": [
"resources/data_cost_input_value/oil_regional_2026-09-09.json"
],
"latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다."
},
{
"source_id": "rates",
"kind": "rate",
"kind_ko": "요율",
"name_ko": "토목공사 원가계산 제비율 적용기준",
"publisher": "조달청",
"where_to_get": "조달청 https://www.pps.go.kr | [정보제공] > [업무별자료] > [시설공사] > 「시설공사 원가계산 간접공사비(제비율) 적용기준」 토목 엑셀(재게시 kseis https://www.kseis.co.kr 자료실)",
"cycle": "해마다 + 수시(법이 바뀌면)",
"latest_published": "2026-04-13",
"latest_basis": "2026-09-15 브레인 쪽지",
"our_edition": "2026-04-13",
"our_edition_from": "effective_date",
"status": "current",
"checked_on": "2026-09-16",
"note": "우리 판이 최신이다. 국민연금 요율은 국민연금법(현행 2026-06-17)을 따로 물었다. ⚠ 요율은 **법이 바뀌면 주기와 상관없이** 바뀐다 — 주기만 보고 안심하면 안 된다.",
"files": [
"resources/data_cost_input_value/rates_2026.json"
]
}
]
}
@@ -6,8 +6,8 @@
"files": [
{
"file": "material_surcharge_2026-01-01.json",
"sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f",
"size_bytes": 5087
"sha256": "1394a39be22ac0eb7476e4bc2b81c1e23c365a4ad5b915befe85d3f0f99023d5",
"size_bytes": 5344
}
]
}
@@ -1,6 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "material_surcharge",
"original_tables": "resources/data_cost_input_value/coef_2026.json → variables/surcharge_mat — 원문 표가 실린 자리. ⚠ 값이 겹치지만 이원화가 아니다: 저쪽은 원문 보관이고 읽어 쓰는 정본은 여기(rates_pct)다.",
"effective_date": "2026-01-01",
"source": {
"primary": {
@@ -8,8 +8,14 @@ sources:
- doc: ESTX 정밀분석 문서 11건 (타 AI 작성 → Claude 독립 재검증 2026-08-14)
version: 2026-08-13
loc: original/원가계산/다산소프트/ESTX_*.md
- doc: Aislo 기초단가 자료 출처표
version: 2026-09-15
loc: resources/data_master_sources/sources_2026-09-15.json
- doc: 기초단가 갱신 이력 조사 (Antigravity 조사 → 브레인 전달)
version: 2026-09-16
loc: 본문 §4.1
status: verified
last_updated: 2026-08-15
last_updated: 2026-09-16
---
관련 문서: [원가계산_체계](원가계산_체계.md) · [제비율](제비율.md) · [품셈_적용기준](../04_수량분석정보/품셈_적용기준.md) · [미결 No.17](../../../03_미결_및_확인사항.md)
사용법 문서(본 문서에서 파생): [노임단가_적용](노임단가_적용.md) · [자재단가_적용](자재단가_적용.md) · [기계경비_산정](기계경비_산정.md) · [건설품셈_활용](../04_수량분석정보/건설품셈_활용.md)
@@ -118,6 +124,38 @@ last_updated: 2026-08-15
> [구현] 내역서 산출 흐름과의 연결 (미결 No.17 대상표 기준): 환율및기초자료 표 = ECOS+오피넷 값층 → 중기목록표/중기사용료 = 품셈 기계손료 산식 × 기초값 → 단가산출근거·일위대가 = 품셈(원단위) × 노임·자재 단가 → 재료비·노무비·경비 집계표. 값층 파일에는 **기준일·출처 스탬프 필수**, 내역서에 원천·기준일 명기(§1 관행). 값층 스키마·저장 위치는 개발 단계 협의.
### 4.1 기초단가 다섯의 출처·주기·갱신 이력 (2026-09-16)
이 절은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)와 Antigravity 조사 결과(2026-09-16 브레인 전달)를 함께 기록한 것이다. 조사에서 확인하지 못한 입수 주소는 임의로 보충하지 않았다.
| 구분 | 공표·작성 기관 / 원천 | 통상 갱신 주기 | 우리 보유 판 / 최신 확인 판 |
|---|---|---|---|
| 노임 — 건설 | 대한건설협회 건설업 시중노임단가 | 연 2회, **1월 1일·9월 1일** | 우리 `2026-01-01` / 최신 `2026-09-01`**한 판 뒤처짐** |
| 노임 — 제조 | 중소기업중앙회 중소제조업 직종별 임금 | 반기 | 우리 `2026-07-01` / 최신 판 미확인 |
| 기계 | 국토교통부 건설공사 표준품셈 제8장 건설기계 | 연간 | 우리·최신 `2026-01-01` |
| 자재 | 조달청 관급자재·시설공통자재 | 반기 + 수시 | 우리 `2026-08-14` / 수시 자료라 단일 최신일 미확인 |
| 유가 | 한국석유공사 오피넷 | 주간·일별 | 전국 `2026-08-14`, 시도별 `2026-09-09` / 고정 최신일 없음 |
| 요율 | 조달청 토목공사 원가계산 제비율 적용기준 및 개별 법령·고시 | 연간 + 법령·고시 개정 시 수시 | 우리·최신 확인 `2026-04-13` |
- 건설 노임 입수처: **대한건설협회 `cak.or.kr` → [지원·사업] → [건설적산기준] → [건설임금]**. 현재 프로그램 자료 [`labor_const_2026-01-01.json`](../../../../data_cost_input_value/labor_const_2026-01-01.json)은 2026년 9월 1일 판을 아직 반영하지 않았다.
- 제조 노임 입수처: **중소기업중앙회 `kbiz.or.kr` → [정보마당] → [조사연구통계](게시판 `mnSeq=325`) → 「중소제조업 직종별 임금조사보고서」 PDF**. 최신 판은 **근거 미확인**이다.
- 기계·유가·요율의 판정 기준과 파일별 기준일은 [기초단가 자료 출처표](../../../../data_master_sources/sources_2026-09-15.json)를 따른다. 조달 자재는 수시 공고라 단일 최신 공표일이 정해지지 않는다.
#### 갱신할 때 행 구성이 바뀌는 범위
| 데이터 | 확인된 변경 이력·규모 | 갱신 시 주의 |
|---|---|---|
| 건설 노임 직종 | 2010년 **145→117**(28직종 통폐합), 2018년 **117→123**, 2020-09-01 **123→127**, 2025-01-01 **127→132** | 직종 코드를 고정 목록으로 보지 말고 신설·통폐합·명칭 변경을 함께 대조한다. |
| 자재 | 반기마다 대략 **100~300개(2~5%) 퇴출**, **수십~수백 개 신설** | 물품분류번호만으로 기존 행을 덮어쓰지 말고 규격·단위·인도조건까지 비교한다. 수치는 조사 관측 범위이며 매 판의 고정값은 아니다. |
| 기계 | 기종 신설·삭제가 잦다. 현재 취득가격 **613행** 중 손료계수가 연결되는 것은 **387행**이다. | 취득가격 행과 손료계수 행을 같은 목록으로 간주하지 않는다. 613/387은 [`labels_2026-01-01.json`](../../../../data_master_labels/labels_2026-01-01.json)의 현행 데이터 전수검사 값이다. |
#### 번호가 같아도 행의 뜻이 달라지는 사례
- **노임 코드 3001~3018**: 국가유산기본법 체계 전환(2024~2025)에 따라 문화재직종에서 국가유산직종으로 명칭·뜻이 바뀌었다. 예: `문화재목공 → 한식목공`. 코드가 같다는 이유만으로 구판 값과 신판 값을 같은 직종으로 자동 승계하면 안 된다.
- **자재 물품분류번호**: 번호를 유지한 채 KS 개정 등에 따라 철근 강종(`SD300/SD400/SD500`), 레미콘 기준, 인도조건(`현장하차도 ↔ 공장상차도`), 포장단위가 달라질 수 있다. 갱신 판정 키에는 번호뿐 아니라 **품명·규격·단위·인도조건**을 포함한다.
> [운영] 판 교체는 단가만 바꾸는 작업이 아니다. 행의 신설·삭제와 같은 번호의 의미 변경을 별도 목록으로 남기고, 의미가 달라진 행은 자동 연결하지 않는다. 위 사례는 Antigravity가 확인해 전달한 조사 결과이며, 그 밖의 변경 사례는 **근거 미확인**으로 둔다.
## 5. 실무 임도 내역서 자재 실측 (2026-08-14 — 유료지 필요성 판단 자료)
울진 간선임도(2024, 기번3) 내역 xlsx의 재료비목록표·자재단가대비표 전수 관측. 영월(기번6)도 동일 시트 구조 확인 (STmate 시트 구성 = 미결 No.17 대상표와 일치).
@@ -0,0 +1,39 @@
"""B09 기계 수송비 — 거리가 비면 **사유가 붙은 빈 줄**이 선다(2026-09-16 브레인 · 사용자 지시).
기계경비는 손료 + 운전경비 + **수송비**(건설품셈 8-1-6 1)인데, 수송비는 거리(인근 ··구청
소재지 현장) 설계 입력이라 넣으면 줄이 섰다. 그때 **사유도 떠서** 모든 프로젝트에서
수송비가 조용히 0 이었다(2026-09-16 조사) 조용히 빠짐 우리가 내내 막아 자리다.
금액은 지어내지 않는다. **사유만** 세운다 문구는 아래 (`B09_Estimation_Transport`) 그대로.
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
def test_거리가_비면_사유가_서고_금액은_안_섬() -> None:
build = cached_build()
assert f"B-{WORK_ITEM_CODE}" not in build.book.titles # 금액을 지어내지 않음
gap = build.component_gaps.get(WORK_ITEM_CODE) or ""
assert "거리" in gap and "산출 조건" in gap
assert gap in build.transport_notes # 아래 층 문구 그대로 — 새로 짓지 않음
def test_거리를_넣으면_줄이_서고_사유는_사라짐() -> None:
build = cached_build(transport_distance_km="12", transport_road="paved")
codes = [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")]
assert codes, "거리를 넣었는데 수송비 줄이 안 섬"
assert WORK_ITEM_CODE not in build.component_gaps
detail = build.book.details[codes[0]][0]
assert detail.quantity > Decimal(0)
def test_한_갈래만_서면_사유는_그_갈래에만_남음() -> None:
"""고속4차선은 원문에 트레일러 속도가 「-」 — 덤프 줄은 서고 트레일러만 사유."""
build = cached_build(transport_distance_km="12", transport_road="expressway_4")
assert [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")]
assert build.transport_notes and any("원문에 없습니다" in n for n in build.transport_notes)
assert WORK_ITEM_CODE not in build.component_gaps # 줄이 섰으면 공종 사유는 안 붙음
@@ -0,0 +1,238 @@
"""기초단가 다섯 표 — **열 이름이 영문으로 떨어지면 빨강**.
시험이 있는 까닭(2026-09-15 크로스체크) 창이 각자 초록이었는데도
**26칸이 화면에 영문 key 뜨고 있었다.** 서버는 이름표에서 찾으면 영문 key
조용히 떨어지게 짜여 있고, 이름표 시험은 서버가 실제로 보는 자리를 봤다.
**어긋나도 아무도 모르는 자리**였다.
그래서 시험은 이름표 파일만 읽지 않고 **서버가 내는 응답 그대로** 잰다.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
ROOT = Path(__file__).resolve().parents[2]
LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json"
@pytest.fixture(scope="module")
def labels() -> dict:
return json.loads(LABELS_PATH.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def shown() -> dict[str, list[dict]]:
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다."""
out = {}
for kind in base_prices.KINDS:
columns = base_prices._columns(kind, base_prices.rows(kind))
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
return out
def test_다섯_표가_다_선다(shown):
# 2026-09-16 기초데이터 아홉이 더해져 열넷(사용자 지시 · 브레인) — 다섯은 그 안에 그대로
assert {"labor", "machine", "material", "oil", "rate"} <= set(shown) == set(base_prices.KINDS)
for kind, columns in shown.items():
assert columns, f"{kind} 에 열이 하나도 없다"
def test_영문으로_떨어지는_열이_하나도_없다(shown):
"""⭐ 이 시험이 이번 판의 값진 자리 — 이름표가 서버 틀과 어긋나면 여기서 빨강."""
english = [
f"{kind}/{column['key']}"
for kind, columns in shown.items()
for column in columns
if column["label"] == column["key"]
]
assert not english, (
f"한글 이름이 없어 영문 key 로 뜨는 열 {len(english)}개: {english}\n"
"→ 이름표 `merged_tables[<kind>].columns` 에 그 key 를 더할 것."
)
def test_열_이름이_비지_않았다(shown):
blank = [
f"{kind}/{column['key']}"
for kind, columns in shown.items()
for column in columns
if not str(column["label"]).strip()
]
assert not blank, blank
def test_이름표에_묵은_열이_남아_있지_않다(labels, shown):
"""서버가 안 내는 열을 이름표가 들고 있으면 옛 틀이 굳은 것이다."""
stale = []
for entry in labels["merged_tables"]:
kind = entry["key"]
live = {c["key"] for c in shown.get(kind, ())}
for column in entry["columns"]:
if column["key"] not in live:
stale.append(f"{kind}/{column['key']}")
assert not stale, f"서버가 안 내는 묵은 열 이름표: {stale}"
def test_요율_축이_한_칸으로_안_합쳐졌다(shown):
"""⭐ 2026-09-15 브레인 ④ — 합치면 어느 축의 구간인지 사라지고 `@id` 도 흔들린다.
축은 서버가 원문 그대로 펴고 이름표가 거기에 맞춘다.
"""
keys = {column["key"] for column in shown["rate"]}
for axis in (
"grade",
"year",
"work_type",
"target_amount_bracket",
"estimated_amount_bracket",
"estimated_price_bracket",
"direct_cost_bracket",
"duration_bracket",
):
assert axis in keys, f"요율 축 {axis} 가 사라졌다"
def test_요율_줄_열쇠에_축이_살아_있다():
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다."""
rows = base_prices.rows("rate")
multi = [r for r in rows if "/" in str(r["@id"])]
assert multi, "축이 든 줄 열쇠가 하나도 없다"
assert any("=" in str(r["@id"]) for r in multi)
assert len({r["@id"] for r in rows}) == len(rows), "줄 열쇠가 겹친다"
def test_기계_시간당_단가는_계산값으로_잠긴다(shown):
"""밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다)."""
meta = base_prices.table("machine", page=1, size=1)
assert set(meta["formula"]) == {
"hourly_loss_krw",
"hourly_fuel_krw",
"hourly_operator_krw",
"hourly_total_krw",
}
for key in meta["formula"]:
assert key not in meta["editable"], f"{key} 는 계산값인데 고칠 수 있다"
def test_이름표는_고칠수있나를_안_정한다(labels):
"""판정 두 벌 금지 — `editable` 은 서버만 정한다."""
carried = [
f"{entry['key']}/{column['key']}"
for entry in labels["merged_tables"]
for column in entry["columns"]
if "editable" in column
]
assert not carried, f"이름표가 고칠 수 있나를 들고 있다: {carried}"
def test_단위가_붙어야_할_열에_단위가_있다(shown):
"""「유가 1,858」만 떠서는 리터인지 드럼인지 모른다 — 금액·수량 열은 단위를 단다."""
want = {
"labor": {"daily_wage_krw": "", "hours_per_day": "시간"},
"material": {"price_krw": ""},
"oil": {"price_krw_per_l": "원/L"},
"machine": {
"price_thousand_krw": "천원",
"fuel_rate_l_per_hour": "L",
"fuel_price_krw_per_l": "원/L",
"hourly_total_krw": "",
},
"rate": {"rate_percent": "%", "base_amount_krw": ""},
}
bad = []
for kind, expected in want.items():
units = {c["key"]: c["unit"] for c in shown[kind]}
for key, unit in expected.items():
if units.get(key) != unit:
bad.append(f"{kind}/{key}: {units.get(key)!r}{unit!r}")
assert not bad, bad
def test_줄_열쇠가_서버_축과_같다(labels):
"""⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다.
이름표가 축을 빠뜨리면 고친 값이 갱신 주인을 잃는다.
"""
declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]}
bad = []
for kind in base_prices.KINDS:
axes = set()
for row in base_prices.rows(kind):
axes |= set(row.get("@axis") or ())
if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다
continue
missing = axes - declared.get(kind, set())
if missing:
bad.append(f"{kind}: 이름표에 없는 축 {sorted(missing)}")
assert not bad, bad
def test_구간_이름은_원문_표기_그대로다():
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다."""
for kind in ("masonry_slope", "masonry_back_length"):
brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)}
assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets))
classes = {
str(r.get("class"))
for r in base_prices.rows("masonry_class")
if r.get("table") == "boulder_diameter"
}
assert classes == {"40~60", "60~80", "80~100"}, sorted(classes)
#: ⚠ **줄마다 축이기도 값이기도 한 칸** — `formwork_reuse.reuse_count` 는
#: `reuse_by_class` 줄에서는 **고치는 값**(갈래별 사용횟수)이고
#: `reuse_ratio_pct` 줄에서는 **축**(몇 회째인가)이다. 서버는 열 하나에 한 판정만 내므로
#: 지금은 고칠 수 있는 칸으로 서 있다. 비율표 줄에서 이 칸을 고치면 **다른 줄로 옮겨 간다**.
#: 2026-09-16 크로스체크에서 드러났고 판정 대기 — 고치기 전까지 여기 예외로 적어 둔다.
AXIS_ALSO_VALUE = {"formwork_reuse/reuse_count"}
def test_축_칸은_못_고친다():
"""축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다."""
bad = []
for kind in base_prices.KINDS:
rows = base_prices.rows(kind)
axes = set()
for row in rows:
axes |= set(row.get("@axis") or ())
if not axes:
continue
meta = base_prices.table(kind, page=1, size=1)
for axis in axes:
if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE:
bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다")
assert not bad, bad
def test_기초단가_상자_이름이_이름표에_있다(labels):
"""⭐ 서버가 내는 갈래(`group`)마다 한글 이름이 있어야 화면이 영문으로 안 뜬다.
서버가 읽을 자리는 `labels["base_price_groups"][group]["name_ko"]`
창이 서로 다른 자리를 보면 2026-09-15 26 사고가 되풀이된다.
"""
named = labels["base_price_groups"]
groups = {base_prices.kind_group(kind) for kind in base_prices.KINDS}
missing = groups - set(named)
assert not missing, f"이름표에 없는 갈래: {sorted(missing)}"
for key, entry in named.items():
assert entry["name_ko"].strip(), key
assert entry["summary"].strip(), key
assert named["base_price"]["name_ko"] == "기초단가"
assert named["pumsem_basis"]["name_ko"] == "품셈 기준"
def test_상자마다_kind_가_있다():
"""빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다."""
counted: dict[str, int] = {}
for entry in base_prices.kinds():
counted[entry["group"]] = counted.get(entry["group"], 0) + 1
assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다"
assert entry["rows"] > 0, entry["kind"]
assert all(n > 0 for n in counted.values()), counted
@@ -0,0 +1,557 @@
"""마스터 이름표(`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 밖이라 따로 센다.
#: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와
#: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다.
BRAIN_KIND_COUNTS = {"logic": 15, "base_value": 9, "byproduct": 4, "seed": 1, "reference": 2}
MANIFEST_PREFIX = "manifest_"
META_KEYS = {
"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"] == "미판정"
#: 2026-09-16 사용자 판정 — 기초데이터 아홉이 더해져 열넷.
#: 곧이어 코드에 박혀 있던 기계 시공능력을 꺼내 열다섯(브레인 b6fb3725).
#: 잣대 = 계산되는 값이 아니라 **그냥 값만으로 정의되는 데이터셋**.
MERGED_KEYS = (
"labor",
"material",
"oil",
"rate",
"machine",
"coef",
"material_surcharge",
"formwork_reuse",
"rebar_complexity",
"timber_structure_class",
"masonry_slope",
"masonry_back_length",
"stone_kind",
"masonry_class",
"machine_productivity",
)
def test_기초데이터_열넷이_다_있다(labels):
merged = {m["key"]: m for m in labels["merged_tables"]}
assert set(merged) == set(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):
"""⚠ 「고칠 수 있나」는 여기서 안 정한다 — 서버 `spec()` 한 곳(판정 두 벌 금지).
영문으로 떨어지는 열이 있는지는 `test_master_labels_base_prices.py` 응답으로 잰다."""
bad = [
f"{entry['key']}/{column['key']}"
for entry in labels["merged_tables"]
for column in entry["columns"]
if not column["name_ko"].strip()
]
assert not bad, bad
def test_기계만_다른_표의_값을_문다(labels):
"""⭐ 기계 시간당 단가는 유가·노임 표를 물어 나온다 — 갱신 차례가 여기서 갈린다."""
merged = {m["key"]: m for m in labels["merged_tables"]}
machine = {c["key"]: c for c in merged["machine"]["columns"]}
for key in ("fuel_price_krw_per_l", "operator_daily_wage_krw"):
assert key in machine, f"{key} 이름표가 없다"
assert "" in machine[key].get("from", ""), f"{key} 가 어디서 오는지 안 적혔다"
for key in ("labor", "material", "oil", "rate"):
assert len(merged[key]["sources"]) <= 3, key
def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels):
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["rate"]["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"]
+128
View File
@@ -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
+123
View File
@@ -0,0 +1,123 @@
"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값을 쓴다**(2026-09-16 브레인).
우리 고르기 규칙이 물품코드마다 가장 나중 공시, 값이 실린 공시(`prce = 0`)
제값을 덮었다(각형강관 25*25*1.4t `1,460 1,500 0`). 가격이 0 아니라
** 공시에 값이 실린 **이라, 0 값으로 받으면 된다.
조용히 바꾸지 않는다 고친 줄에는 사유가 남고, 공시 날짜·번호도 **채택한 공시의 ** 된다.
공시에도 값이 없어 0 남는 줄은 **빨강이 아니라 목록**(`zero_price_unrecovered`)으로 남는다.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MASTER = ROOT / "resources" / "data_cost_input_value" / "mat_price_public_2026-08-14.json"
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json"
EMPTY_PRICES = {"", "0", "0.0", "0.00"}
@pytest.fixture(scope="module")
def master() -> dict:
return json.loads(MASTER.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def records(master) -> list[dict]:
return master["variables"]["mat_price"]["records"]
def _empty(value) -> bool:
return str(value).strip() in EMPTY_PRICES
def test_값이_없는_줄은_목록에만_남는다(master, records):
"""⭐ 규칙으로 고칠 수 있는 0 원 줄은 하나도 안 남는다.
공시에도 값이 없는 줄만 `zero_price_unrecovered` 남고, 줄은 표에서도 0 이다.
"""
listed = {str(item["item_code"]) for item in master["zero_price_unrecovered"]["items"]}
zeros = {str(r["item_code"]) for r in records if _empty(r.get("price_krw"))}
assert zeros == listed, f"목록에 없는 0 원 줄: {sorted(zeros - listed)}"
def test_못_고친_줄에는_사유가_붙는다(master):
for item in master["zero_price_unrecovered"]["items"]:
assert item["reason"].strip(), item["item_code"]
assert master["zero_price_unrecovered"]["note"].strip()
def test_고친_줄마다_사유가_남는다(records):
"""조용히 바꾸지 않는다 — 「○ 공시에 값이 없어 ○ 값을 씀」이 줄에 남아야 한다."""
repaired = [r for r in records if r.get("price_adopted_reason")]
assert repaired, "고친 줄이 하나도 없다"
for row in repaired:
reason = row["price_adopted_reason"]
assert "값이 없어" in reason and "" in reason, reason
assert not _empty(row["price_krw"]), row["item_code"]
def test_고친_줄의_공시_날짜와_번호가_채택한_공시의_것이다(records):
"""어느 판 값인지 화면에서 보여야 한다 — 날짜가 0 원 공시에 머물면 안 된다."""
raw = json.loads(RAW.read_text(encoding="utf-8"))
history: dict[str, list[dict]] = {}
for row in raw:
history.setdefault(str(row.get("prdctIdntNo")), []).append(row)
for rows in history.values():
rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True)
wrong = []
for row in (r for r in records if r.get("price_adopted_reason")):
adopted = next(
(h for h in history.get(str(row["item_code"]), ()) if not _empty(h.get("prce"))),
None,
)
assert adopted is not None, row["item_code"]
if str(row["price_krw"]) != str(int(str(adopted["prce"]).strip())):
wrong.append(f"{row['item_code']} 단가 {row['price_krw']}{adopted['prce']}")
elif row["notice_datetime"] != str(adopted.get("nticeDt", "")):
wrong.append(f"{row['item_code']} 날짜 {row['notice_datetime']}")
elif row["notice_number"] != str(adopted.get("prceNticeNo", "")):
wrong.append(f"{row['item_code']} 번호 {row['notice_number']}")
elif (
row["price_adopted_reason"].endswith(f"{str(adopted['nticeDt'])[:10]} 값을 씀") is False
):
wrong.append(f"{row['item_code']} 사유가 채택 공시와 다름")
assert not wrong, wrong
def test_0원_아닌_줄은_안_건드린다(records):
"""규칙을 통째로 다시 돌리지 않는다 — 사유가 붙은 줄만 손댄 줄이다."""
touched = [r for r in records if r.get("price_adopted_reason")]
assert len(touched) < len(records) // 10, (
"손댄 줄이 너무 많다 — 규칙이 전체를 다시 쓴 것 아닌가"
)
assert len(records) == 6999, "줄 수가 바뀌었다"
def test_고르는_방침에_0원_건너뛰기가_적혀_있다(master):
policy = master["selection_policy"]
assert "zero" in policy and "skip" in policy.lower(), policy
def test_같은_날짜에_0과_제값이_함께_있어도_제값을_고른다():
"""2025-04-24 는 같은 날 0 과 제값이 함께 실린다 — 날짜만 보면 0 을 집을 수 있다."""
raw = json.loads(RAW.read_text(encoding="utf-8"))
same_day: dict[str, set[bool]] = {}
for row in raw:
if str(row.get("nticeDt", ""))[:10] != "2025-04-24":
continue
same_day.setdefault(str(row.get("prdctIdntNo")), set()).add(_empty(row.get("prce")))
both = [code for code, kinds in same_day.items() if kinds == {True, False}]
assert both, "같은 날 0 과 제값이 함께 있는 물품이 없다 — 시험 전제가 바뀌었다"
master = json.loads(MASTER.read_text(encoding="utf-8"))
rows = {str(r["item_code"]): r for r in master["variables"]["mat_price"]["records"]}
for code in both:
row = rows.get(code)
if row is not None:
assert not _empty(row["price_krw"]), f"{code} 가 0 으로 섰다"
+603
View File
@@ -0,0 +1,603 @@
"""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, ["price_krw_per_l"]),
"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")["price_krw_per_l"] == 1846.39
assert _row(client, "oil", "regional/oil_diesel/01")["region_name"] == "서울"
rate = _get(client, "rate", size=500)
assert rate["total"] == 218 and "rate_percent" in rate["editable"]
by_id = {r["@id"]: r for r in rate["rows"]}
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_total_krw" in table["locked"] and "hourly_total_krw" in table["formula"]
row = next(r for r in table["rows"] if r["@id"] == "0101-0007")
parts = row["hourly_loss_krw"] + row["hourly_fuel_krw"] + row["hourly_operator_krw"]
assert row["hourly_total_krw"] == pytest.approx(parts)
assert (
"hourly_total_krw" in row["@formula"]
and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"]
)
res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1})
assert res.status_code == 400 and table["locked"]["hourly_total_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_total_krw"] is None and rows[code]["hourly_note"], code
continue
if cost.gaps:
assert rows[code]["hourly_total_krw"] is None, code # 반만 선 값은 안 냄
continue
assert rows[code]["hourly_total_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", {"price_krw_per_l": 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", {"price_krw_per_l": 2000})
_put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2})
after = _row(client, "machine", "0101-0007")
assert after["hourly_operator_krw"] > base["hourly_operator_krw"]
assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"]
assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2)
price = base["price_thousand_krw"]
assert after["@overrides"] == {
"price_thousand_krw": {
"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",
"unit",
}
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 any("갱신" in line and "주인" in line for line in rate["notice"])
for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로
notice = _get(client, kind, size=1)["notice"]
assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind
assert len(rate["notice"]) == 2 and len(_get(client, "material", size=1)["notice"]) == 1
def test_막는_자리(client: TestClient) -> None:
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_type"] is None)
assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None
def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None:
"""브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음."""
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"]) # 없는 칸을 새로 만들지 않음
SOURCE_KEYS = {
"source_id",
"name",
"publisher",
"where",
"cycle",
"latest_published",
"our_edition",
"checked_at",
"outdated",
}
def test_표마다_출처와_뒤처짐을_서버가_판정(client: TestClient) -> None:
"""브레인 — 건설 노임이 한 판 뒤처진 것을 반 년간 아무도 몰랐음 → 표 수준에 출처 · outdated 는 서버 판정."""
labor = {s["source_id"]: s for s in _get(client, "labor", size=1)["source"]}
const = labor["labor_const"]
assert set(const) == SOURCE_KEYS
assert (const["our_edition"], const["latest_published"], const["outdated"]) == (
"2026-01-01",
"2026-09-01",
True,
)
assert "cak.or.kr" in const["where"] and const["publisher"] == "대한건설협회"
assert const["checked_at"] == "2026-09-15"
assert labor["labor_mfg"]["outdated"] is None # 최신 공표일 모름
machine = {s["source_id"]: s for s in _get(client, "machine", size=1)["source"]}
assert (
machine["machine_operating"]["our_edition"] == "2026-01-01"
) # derived_from.effective_date
assert machine["mach_base"]["outdated"] is False
assert [s["source_id"] for s in _get(client, "oil", size=1)["source"]] == [
"oil",
"oil_regional",
]
def test_우리_판은_출처표_글자가_아니라_파일에서_읽음(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
real = sorted((ROOT / "resources/data_master_sources").glob("sources_*.json"))[-1]
doc = json.loads(real.read_text(encoding="utf-8"))
for s in doc["sources"]:
if s["source_id"] == "rates": # 표 글자가 굳어 틀려도
s.update(our_edition="1999-01-01", latest_published="2027-01-01", status="current")
folder = tmp_path / "sources"
folder.mkdir()
(folder / "sources_2099-01-01.json").write_text(json.dumps(doc), encoding="utf-8")
monkeypatch.setattr(base_prices, "SOURCES_DIR", folder)
rate = _get(client, "rate", size=1)["source"]
assert [(s["our_edition"], s["outdated"]) for s in rate] == [("2026-04-13", True)]
monkeypatch.setattr(base_prices, "SOURCES_DIR", tmp_path / "없음")
assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로)
def test_모아_보기_항목에_단위(client: TestClient) -> None:
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
(item,) = client.get("/api/master-data/overrides").json()["items"]
assert item["unit"] == "" and item["column_label"] == "일 노임"
def test_열_이름은_이름표에서만_옴(client: TestClient) -> None:
"""브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본."""
labels = json.loads(
(ROOT / "resources/data_master_labels/labels_2026-01-01.json").read_text(encoding="utf-8")
)
merged = {t["key"]: t for t in labels["merged_tables"]}
for kind in ("labor", "material", "oil"):
named = {c["key"]: c for c in merged[kind]["columns"]}
for column in _get(client, kind, size=1)["columns"]:
if column["key"] in named:
assert column["label"] == named[column["key"]]["name_ko"], (kind, column["key"])
assert column["unit"] == named[column["key"]]["unit"]
assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], (
kind
)
def _sorted_rows(client: TestClient, kind: str, column: str, desc: int = 0, **params) -> list:
table = _get(client, kind, sort=column, desc=desc, size=500, **params)
return [r.get(column) for r in table["rows"]]
def test_차례_세우기는_서버가_전체_줄로(client: TestClient) -> None:
"""브레인 — 자재 140 쪽이라 화면이 받은 50 줄만 세우면 1 쪽 안에서만 맞는 가짜 차례."""
table = _get(client, "material", size=1)
assert set(table["sortable"]) == {c["key"] for c in table["columns"]}
first = _get(client, "material", sort="price_krw", size=1)
last = _get(client, "material", sort="price_krw", desc=1, size=1)
assert first["total"] == last["total"] == 6999
assert first["rows"][0]["price_krw"] < last["rows"][0]["price_krw"]
page2 = _get(client, "material", sort="price_krw", size=50, page=2)["rows"]
assert first["rows"][0]["price_krw"] <= page2[0]["price_krw"] # 쪽을 넘어도 이어짐
values = _sorted_rows(client, "material", "price_krw")
assert values == sorted(values)
def test_거른_뒤에_세움_모르는_열은_거절(client: TestClient) -> None:
hits = _get(client, "material", q="육각볼트", sort="price_krw", desc=1, size=500)
values = [r["price_krw"] for r in hits["rows"]]
assert values == sorted(values, reverse=True) and hits["total"] == len(values)
assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in hits["rows"])
bad = client.get("/api/master-data/base-prices/material", params={"sort": "없는열"})
assert bad.status_code == 400 and "없는열" in bad.text # 조용히 원본 차례로 안 돌아감
def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None:
"""미공표 노임 14 · 손료계수 없는 기계 226 이 첫 쪽을 채우면 표를 못 씀."""
for desc in (0, 1):
wages = _sorted_rows(client, "labor", "daily_wage_krw", desc)
filled = [v for v in wages if v is not None]
assert wages[: len(filled)] == sorted(filled, reverse=bool(desc))
assert wages[len(filled) :] == [None] * (len(wages) - len(filled)) and len(filled) < len(
wages
)
hourly = _sorted_rows(client, "machine", "hourly_total_krw", 1)
assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐
def test_덮개_얹은_값으로_세움(client: TestClient) -> None:
_put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000})
top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0]
assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000
def test_sort_가_없으면_원본_차례(client: TestClient) -> None:
assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae"
assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"
def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None:
"""브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격).
값만 보면 잡힘 덮개를 **값이 아닌 글자 **(이름·규격·단위) 함께 적어 두고 견줌.
"""
saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json()
assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED
entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"]
assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만
assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음
assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900})
oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"]
assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"]
entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름"
(store / "labor.json").write_text(
json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8"
)
mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]
assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜
# 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌)
assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published"
assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published"
listed = client.get("/api/master-data/overrides").json()
top = listed["items"][0]
assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게
assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘
assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED
assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED)
def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None:
_put(client, "machine", "0101-0007", {"price_thousand_krw": 80000})
meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][
"0101-0007"
]["price_thousand_krw"]["meaning"]
assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7"
assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")]
store.mkdir(
exist_ok=True
) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음)
(store / "material.json").write_text(
json.dumps(
{"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}}
),
encoding="utf-8",
)
assert (
_row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED
)
+304
View File
@@ -0,0 +1,304 @@
"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1 을 기초단가와 **같은 길**로(2026-09-16 사용자 지시 · 브레인).
잣대(사용자): 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.**
태움 coef(토량환산) · material_surcharge · formwork_reuse · rebar_complexity · timber_structure_class ·
masonry_slope · masonry_back_length · stone_kind · masonry_class
태움 이음표(type_map·form_map·bond.codes) · 품셈 원문 (coef surcharge_*) · ·방침 ·
structure_unit_observed(울진 현장 관찰값) · revetment_sabang(교본값 + 섞임) 트리엔 남되 고치기 없음
coef 원문 중복 암괴점토 줄은 이름·값이 모두 같아 ** 줄로 합치고 사유**(브레인 ) ·
합친 줄인데 값이 갈리면 **빨강**(품셈 개정으로 줄이 달라지는 조용히 지나가면 ).
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
NEW_KINDS = (
"coef",
"material_surcharge",
"formwork_reuse",
"rebar_complexity",
"timber_structure_class",
"masonry_slope",
"masonry_back_length",
"stone_kind",
"machine_productivity", # 2026-09-16 코드에서 꺼낸 기계 작업량 밑값
"masonry_class",
)
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
return TestClient(app)
def _get(client: TestClient, kind: str, **params) -> dict:
res = client.get(f"/api/master-data/base-prices/{kind}", params=params)
assert res.status_code == 200, res.text
return res.json()
def _ids(table: dict) -> list[str]:
return [r["@id"] for r in table["rows"]]
def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None:
assert base_prices.KINDS == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS
for kind in NEW_KINDS:
table = _get(client, kind, size=500)
assert table["total"] == len(table["rows"]) > 0, kind
ids = _ids(table)
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음
assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind
assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글
assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind
axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())}
assert {table["locked"][c] for c in axis if c in table["locked"]} == {
"자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
}, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함
assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind
assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로)
def test_돌쌓기_경사는_도면에도_닿는다고_알림(client: TestClient) -> None:
"""아홉 중 하나만 수량 말고 **그림**에도 닿음 — 화면이 사전을 가지면 문구가 두 벌(브레인)."""
slope = _get(client, "masonry_slope", size=1)["notice"]
assert any("횡단 도면" in line for line in slope) and len(slope) == 2
assert all(
not any("횡단 도면" in line for line in _get(client, kind, size=1)["notice"])
for kind in NEW_KINDS
if kind != "masonry_slope"
)
def test_값표만_태우고_이음표는_안_태움(client: TestClient) -> None:
formwork = " ".join(_ids(_get(client, "formwork_reuse", size=500)))
assert "type_map" not in formwork and "euroform_type/classes" in formwork
rebar = " ".join(_ids(_get(client, "rebar_complexity", size=500)))
assert "form_map" not in rebar and "classes/" in rebar
timber = " ".join(_ids(_get(client, "timber_structure_class", size=500)))
assert "type_map" not in timber
coef = " ".join(_ids(_get(client, "coef", size=500)))
assert "surcharge" not in coef and "rate_tool" not in coef # 품셈 원문 표·낱값은 여기 아님
masonry = " ".join(_ids(_get(client, "masonry_class", size=500)))
assert "bond" not in masonry # 공종코드 잇는 표
for gone in ("structure_unit_observed", "revetment_sabang"):
assert client.get(f"/api/master-data/base-prices/{gone}").status_code == 404
def test_원문_중복_두_줄은_한_줄로_사유와_함께(client: TestClient) -> None:
rows = {r["@id"]: r for r in _get(client, "coef", size=500)["rows"]}
# 이름 글자가 다른 쌍은 그대로 둘 줄(값도 다름)
assert rows["coef_soil_L/역(礫)이 섞인 점질토"]["min"] == 1.35
assert rows["coef_soil_L/역이 섞인 점질토"]["min"] == 1.3
merged = rows["coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토"]
assert (merged["min"], merged["max"]) == (1.4, 1.45)
assert merged["source_rows"] == 2 and "원문 두 줄" in merged["duplicate_note"]
assert [i for i in rows if i.startswith("coef_soil_L/암괴(岩塊)")] == [
"coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토"
] # 두 줄이 한 줄로
def test_합친_줄인데_값이_갈리면_빨강() -> None:
"""품셈 개정으로 두 줄 값이 갈리는 날 — 조용히 한 줄로 합치면 틀린 값이 섬(브레인 조건)."""
same = [
{"@id": "x", "soil_type": "", "min": 1.0, "max": 1.1},
{"@id": "x", "soil_type": "", "min": 1.0, "max": 1.1},
]
merged = base_prices.merge_same_rows(same)
assert len(merged) == 1 and merged[0]["source_rows"] == 2
differing = [
{"@id": "x", "soil_type": "", "min": 1.0, "max": 1.1},
{"@id": "x", "soil_type": "", "min": 1.2, "max": 1.3},
]
with pytest.raises(base_prices.DuplicateRowError):
base_prices.merge_same_rows(differing)
def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None:
rows = {r["@id"]: r for r in _get(client, "masonry_slope", size=500)["rows"]}
assert rows["table/메쌓기/성토/~1.5"]["face_slope_ratio"] == 0.3
assert rows["table/찰쌓기/절토/7이상"]["face_slope_ratio"] == 0.4
back = {r["@id"]: r for r in _get(client, "masonry_back_length", size=500)["rows"]}
assert (back["table_cm/메쌓기/~1.5"]["min_cm"], back["table_cm/메쌓기/~1.5"]["max_cm"]) == (
25,
35,
)
assert back["table_cm/메쌓기/7이상"]["max_cm"] is None # 원문 「-」 — 지어내지 않음
stone = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]}
assert stone["wedge_stone_m3_per_m2/깬잡석/25"]["m3_per_m2"] == 0.09
assert stone["fill_concrete_m3_per_m2/견치돌/75"]["m3_per_m2"] == 0.34
assert stone["backfill_ratio_of_back_length/깬돌"]["ratio"] == 0.5
ratio = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]}
assert ratio["reuse_ratio_pct/plywood/2"]["ratio_pct"] == 57.0
assert ratio["reuse_by_class/복잡한 구조"]["reuse_count"] == 2
timber = {r["@id"]: r for r in _get(client, "timber_structure_class", size=500)["rows"]}
assert timber["classes/보통구조 하"]["carpenter"] == 6.285
surcharge = {r["@id"]: r for r in _get(client, "material_surcharge", size=500)["rows"]}
assert (surcharge["rates_pct/시멘트"]["rate"], surcharge["rates_pct/시멘트"]["alt_rate"]) == (
2,
3,
)
def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None:
res = client.put(
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
json={"values": {"m3_per_m2": 0.1}},
)
assert res.status_code == 200, res.text
row = res.json()
assert row["m3_per_m2"] == 0.1
assert row["@overrides"]["m3_per_m2"]["original"] == 0.09
assert (tmp_path / "data_master_override" / "stone_kind.json").is_file()
listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json()
assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"]
blocked = client.put(
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
json={"values": {"stone_kind": "딴 돌"}},
)
assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침
assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄
# 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함
axis_edit = client.put(
"/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood/2",
json={"values": {"reuse_count": 3}},
)
assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text
value_edit = client.put(
"/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조",
json={"values": {"reuse_count": 3}},
)
assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3
def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None:
"""사용자 원칙 — 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨**(2026-09-16).
불도저 속도·삽날(건설품셈 8-2-1) 덤프 운반·적재 계수(산림품셈 10-12 서식) 꺼낸 자리.
"""
table = _get(client, "machine_productivity", size=500)
rows = {r["@id"]: r for r in table["rows"]}
assert table["total"] == 25 + 10 + 9 + 3
assert rows["dozer_speed/무한궤도/7/2"]["forward_m_per_min"] == 67
assert rows["dozer_speed/타이어/33/2"]["reverse_m_per_min"] == 250
assert rows["dozer_blade/무한궤도/19"]["blade_m3"] == 3.2
assert rows["dump_haul/truck_ton"]["value"] == 15
assert rows["dump_material/FP-10-12-01"]["loose_factor"] == 1.3
assert "forward_m_per_min" in table["editable"] and "track" in table["locked"]
def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None:
"""옮기며 값이 바뀌면 금액이 조용히 달라짐 — 코드가 읽는 값과 품셈 원문 둘 다에 댐."""
import json
import re
from decimal import Decimal
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import _DOZER_SPEEDS
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import DUMP_MATERIALS
assert _DOZER_SPEEDS["무한궤도"][Decimal("12")][2] == (Decimal(55), Decimal(70))
assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9")
from B09_Estimation import B09_Estimation_MachineProductivity_Dump as dump
assert (dump._TRUCK_TON, dump._BUCKET_M3, dump._LOADER_CYCLE_SEC) == (
Decimal(15),
Decimal("0.7"),
Decimal(20),
)
assert (dump._V_LOADED_KMH, dump._V_EMPTY_KMH, dump._LOADING_CYCLE_SEC) == (
Decimal(5),
Decimal(6),
Decimal(22),
)
pum = json.loads(
(ROOT / "resources/data_cost_input_value/pum_const_2026.json").read_text(encoding="utf-8")
)["variables"]["pum"]["tables"]
by_id = {t["table_id"]: t for t in pum}
def source(table_id: str, gears: int) -> dict:
out = {}
for row in by_id[table_id]["rows"][1:]:
ton = re.sub(r"\(.*?\)", "", str(row[0])).strip()
if not ton.replace(".", "").isdigit():
continue
cells = [str(v).strip() for v in row[1:]]
out[Decimal(ton)] = {
i + 1: (f, r)
for i, (f, r) in enumerate(zip(cells[:gears], cells[gears : gears * 2]))
}
return out
for track, table_id, gears in (("무한궤도", "C0426", 4), ("타이어", "C0427", 3)):
original = source(table_id, gears)
for ton, speeds in _DOZER_SPEEDS[track].items():
for gear, (forward, reverse) in speeds.items():
assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear)
def test_계산값은_고칠_칸이_아니고_까닭이_보임(client: TestClient) -> None:
"""코덱스 검증 1 — 철근 단가 참고값은 **B09 계산값**인데 고칠 수 있는 마스터 값으로 앉아 있었음."""
rebar = _get(client, "rebar_complexity", size=10)
assert "price_krw_per_ton" not in rebar["editable"]
assert "계산값" in rebar["locked"]["price_krw_per_ton"]
assert "표시 전용" in rebar["formula"]["price_krw_per_ton"]
machine = _get(client, "machine", size=1) # 기계 계산 넷도 같은 꼴(잠김 + 식)
for column in ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw"):
assert "계산값" in machine["locked"][column] and column in machine["formula"]
def test_같은_기울기가_두_자리인_까닭을_표가_말함(client: TestClient) -> None:
"""코덱스 검증 2 — 값은 같은 1:0.3 이나 **축과 근거가 다름**(품셈 직고·성절토 ↔ 교본 형식별)."""
notice = _get(client, "masonry_class", size=1)["notice"]
assert any("교본 7-3" in line and "돌쌓기 표준경사" in line for line in notice)
assert any("축과 근거가 다름" in line for line in notice)
slope = _get(client, "masonry_slope", size=1)["notice"]
assert any("횡단 도면" in line for line in slope) # 표준경사 쪽 알림은 그대로
def test_kind_목록을_서버가_줌_새_kind_도_저절로(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""화면이 kind 목록을 제 코드에 들면 **새 kind 가 조용히 안 뜸**(기계 작업량이 그랬음 · 브레인).
갈래·kind·한글 이름· 수를 서버가 . 화면은 상자만 세운다.
"""
listed = client.get("/api/master-data/base-prices")
assert listed.status_code == 200, listed.text
items = listed.json()["kinds"]
assert [i["kind"] for i in items] == list(base_prices.KINDS)
groups = {i["kind"]: i["group"] for i in items}
assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis"
assert {i["group"] for i in items} == {"base_price", "pumsem_basis"}
labor = next(i for i in items if i["kind"] == "labor")
assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서
assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
fake = dict(base_tables.SPEC)
fake["coef_fake"] = dict(base_tables.SPEC["coef"])
monkeypatch.setattr(base_tables, "SPEC", fake)
monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "coef_fake"))
grown = client.get("/api/master-data/base-prices").json()["kinds"]
assert [i["kind"] for i in grown][-1] == "coef_fake" # 더하면 목록이 저절로 늚
assert (
next(i for i in grown if i["kind"] == "coef_fake")["label"] == "coef_fake"
) # 이름표 전엔 영문
+331
View File
@@ -0,0 +1,331 @@
"""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,
boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
} from "./Z01_MasterData_UI_Cells.js";
// kind 목록은 서버가 화면이 목록을 들면 kind 때마다 빠짐(2026-09-16 브레인)
const served = [
{ group: "base_price", kind: "labor", label: "노임", rows: 261 },
{ group: "base_price", kind: "oil", label: "유가", rows: 36 },
{ group: "pumsem_basis", kind: "coef", label: "토량환산계수", rows: 32 },
// 서버가 kind· 갈래를 더해도 화면이 그대로 세움
{ group: "pumsem_basis", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
{ group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 },
];
const grouped = groupKinds(served).map((g) => [g.group, g.items.map((i) => i.kind)]);
// 제목 누르기 없음 오름 내림 없음(원본 차례) · 다른 열을 누르면 오름부터
const sorts = [
nextSort(null, "price_krw"),
nextSort({ key: "price_krw", desc: false }, "price_krw"),
nextSort({ key: "price_krw", desc: true }, "price_krw"),
nextSort({ key: "price_krw", desc: true }, "item_code"),
];
// 수준 알림 서버가 벌로 목록을 ( 새로 만드는 프로젝트부터) · 글자에 **굵게** 섞임
const notices = [
noticeLines(["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"]),
noticeLines("한 줄짜리"),
noticeLines(undefined),
];
const bolds = [
boldSegments("여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임"),
boldSegments("굵게 없음"),
];
// 뒤처짐 판정은 서버 outdated (화면이 날짜를 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인)
// 표가 원본 여럿을 모은 것이라 출처는 목록으로 (노임 = 건설업 + 제조업).
const laborSources = [
{
source_id: "labor_const",
name: "건설업 시중노임단가",
publisher: "대한건설협회",
where: "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]",
cycle: "해마다 두 번",
latest_published: "2026-09-01",
our_edition: "2026-01-01",
checked_at: "2026-09-15",
outdated: true,
},
{
source_id: "labor_mfg",
name: "중소제조업 직종별 임금",
publisher: "중소기업중앙회",
where: "",
latest_published: null,
our_edition: "2026-07-01",
outdated: null,
},
];
const banners = [
outdatedBanner(laborSources),
outdatedBanner([{ ...laborSources[0], outdated: false }]),
outdatedBanner(undefined),
];
// 기준일 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 모름(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 (화면이 originalcurrent 따로 )
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);
// 바뀜 번호는 그대로인데 줄이 물건이 (노임 3001 문화재목공 한식목공)
const meaningRow = {
"@id": "labor_const/3001",
daily_wage_krw: 300000,
"@overrides": {
daily_wage_krw: {
original: 250000,
value: 300000,
current: 260000,
state: "meaning_changed",
was: "문화재목공",
now: "한식목공",
},
},
};
const wait = (ms, value, fail) =>
new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms));
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),
// 소수 떠돌이 자리(부동소수) 기계 손료계수 0.0001811 0.00018109999999999998 보이던
cellText(0.00018109999999999998, null),
],
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
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"),
cellInfo(meta, meaningRow, "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억 미만", ""),
],
banners,
notices,
bolds,
sorts,
grouped,
}));
"""
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}',
"",
"보통인부",
"0.0001811",
]
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, meaning = 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 meaning["state"] == "meaning_changed" and meaning["current"] == 260000
assert meaning["was"] == "문화재목공" and meaning["now"] == "한식목공"
assert hourly["state"] is None and plain["state"] is None
assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴"
assert hourly["overridden"] is False and "original" not in hourly
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억 미만"]
# 뒤처진 원본만 한 줄씩 · 판정을 모르는 것(outdated null)은 안 세움 · 없으면 빈 목록
assert got["banners"] == [
[
"건설업 시중노임단가 — 이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · "
"대한건설협회 cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]"
],
[],
[],
]
# 알림은 목록도 한 줄도 받음 · 없으면 빈 목록
assert got["notices"] == [
["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"],
["한 줄짜리"],
[],
]
# **…** 는 굵게 조각으로 갈라 보임 — 별표가 글자로 남으면 안 됨
assert got["bolds"] == [
[
{"text": "여기서 고친 값은 ", "bold": False},
{"text": "새로 만드는 프로젝트부터", "bold": True},
{"text": " 쓰임", "bold": False},
],
[{"text": "굵게 없음", "bold": False}],
]
# 세 번째 누르면 원본 차례로 — 누르기 전에는 차례 표시가 아예 없어야 함
assert got["sorts"] == [
{"key": "price_krw", "desc": False},
{"key": "price_krw", "desc": True},
None,
{"key": "item_code", "desc": False},
]
# 서버 차례 그대로 갈래별로 묶임 · 새 kind·새 갈래도 저절로 섬(화면 목록이 없음)
assert got["grouped"] == [
["base_price", ["labor", "oil"]],
["pumsem_basis", ["coef", "machine_productivity"]],
["later_group", ["brand_new"]],
]
+271
View File
@@ -0,0 +1,271 @@
"""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}
# 2026-09-16 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다.
assert counts == {
"logic": 15,
"base_value": 9,
"byproduct": 4,
"reference": 2,
"fingerprint": 3,
"seed": 1,
}
assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값"
by_id = {f["file_id"]: f for f in LABELS["files"]}
for f in (f for g in groups for f in g["files"]):
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
View File
@@ -32,6 +32,6 @@
"maplibre-gl": ["config/node_modules/maplibre-gl"],
}
},
"include": ["A00_Common", "A0*", "B0*", "B1*", "ui_template", "config/**/*", "common_util"],
"include": ["A00_Common", "A0*", "B0*", "B1*", "Z0*", "ui_template", "config/**/*", "common_util"],
"exclude": ["0_old", "node_modules", "venv", "dist"]
}
+87
View File
@@ -136,4 +136,91 @@ export const ui_locales_b3 = {
"법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)",
"Only the stations required by the forest act table 2 are automatic — grade > 8% sandy/clay soil (our preset has one soil class) and ≤ 8% soft/wet ranges you enter. Manual 3-2 covers all non-concrete stations. Suggested thickness 0.10 (manual) · C·L from practice (no gravel row in the standard table)",
],
/* --- B01 시스템 설정 · Z01 마스터 데이터 (2026-09-15 브레인 Z01) --- */
B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"],
B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"],
Z01_MasterData_Title: ["마스터 데이터", "Master Data"],
Z01_MasterData_Tree: ["마스터 트리", "Master tree"],
/* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */
Z01_MasterData_List_Kind: ["표", "Table"],
Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"],
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_MeaningChanged: [
"⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)",
"⛔ Edited — the row now means something else (same code, different item)",
],
Z01_MasterData_State_MeaningMissing: [
"⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그",
"⛔ Server sent no before/after row names — bug",
],
Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"],
Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"],
Z01_MasterData_Edit_DropOnMeaning: [
"고친 값 버리고 지금 줄 값 {value} 쓰기",
"Drop the edit and use the current row value {value}",
],
Z01_MasterData_Edit_MeaningConfirm: [
"줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?",
"This row now means something else. Dropping the edit uses the current row's value. Continue?",
],
Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"],
Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"],
Z01_MasterData_State_Orphan: [
"⚠ 주인 없음 — 갱신으로 그 줄이 사라짐",
"⚠ Orphan — the row disappeared on update",
],
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;
+19 -17
View File
@@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos";
export interface WorkflowOverlayOptions {
title: string;
progressContent: HTMLElement;
/** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */
progressContent?: HTMLElement;
optionsContent?: HTMLElement;
showTitlePanel?: boolean;
/** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */
showProjectName?: boolean;
onOptionsOpenChange?: (isOpen: boolean) => void;
}
@@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
const root = document.createElement("div");
root.className = "ui-workflow-overlay";
const progressBody = document.createElement("div");
progressBody.append(options.progressContent);
const progressPanel = createPanel(
"progress",
t("Workflow_Progress_Title"),
progressBody,
PROGRESS_OVERLAY_STATE_KEY,
);
let setTitleOpen = (_isOpen: boolean): void => {};
if (options.showTitlePanel !== false) {
const titleBody = document.createElement("div");
@@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
titleBody,
TITLE_OVERLAY_STATE_KEY,
options.onOptionsOpenChange,
createProjectNameTag(),
options.showProjectName === false ? undefined : createProjectNameTag(),
);
root.append(titlePanel.root);
setTitleOpen = titlePanel.setOpen;
}
root.append(progressPanel.root);
let setProgressOpen = (_isOpen: boolean): void => {};
if (options.progressContent) {
const progressBody = document.createElement("div");
progressBody.append(options.progressContent);
const progressPanel = createPanel(
"progress",
t("Workflow_Progress_Title"),
progressBody,
PROGRESS_OVERLAY_STATE_KEY,
);
root.append(progressPanel.root);
setProgressOpen = progressPanel.setOpen;
}
return {
root,
setTitleOpen,
setProgressOpen: progressPanel.setOpen,
};
return { root, setTitleOpen, setProgressOpen };
}