refactor(M01): Z01_MasterData 폴더째 old_code 로 옮기고 등록·문구 끊음 (PLAN 7-2)
라우터·라우트·문구에서 Z01 을 끊음 — M01 만 남김. 시험 여섯도 old_code/resources/tester 로 같이 옮김. Z01 이 읽던 옛 자료 셋은 디스크에 이미 없음 · master_data/old 는 B08·B09 도 읽어 남김. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
/** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */
|
||||
filters?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·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");
|
||||
}
|
||||
for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value);
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
"""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
|
||||
import re
|
||||
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
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
#: 기초단가 다섯 + 기초데이터 열 + 공종 축 둘(산림·건설) — 이름은 자료 이름 그대로
|
||||
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work_items.KINDS)
|
||||
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",
|
||||
)
|
||||
#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
|
||||
#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠.
|
||||
_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))
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.base_rows(kind)
|
||||
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):
|
||||
"""모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④)."""
|
||||
|
||||
|
||||
#: 품셈 번호 꼴(「9-3-2」·「12-17」) — 글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기 · 브레인).
|
||||
_NUMBER_PATH = re.compile(r"^\d+(?:-\d+)+$")
|
||||
|
||||
|
||||
def _order_key(value: Any) -> tuple:
|
||||
"""섞인 칸도 세움 — 수 먼저, 품셈 번호 꼴은 **마디마다 수로**, 그 뒤 글자."""
|
||||
if isinstance(value, bool):
|
||||
return (2, str(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return (0, float(value))
|
||||
if isinstance(value, (dict, list)):
|
||||
return (2, json.dumps(value, ensure_ascii=False, default=str))
|
||||
if isinstance(value, str) and _NUMBER_PATH.match(value):
|
||||
return (1, tuple(int(part) for part in value.split("-")))
|
||||
return (2, 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]:
|
||||
"""표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③)."""
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.notice(kind)
|
||||
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 work_items.KINDS:
|
||||
return work_items.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:
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.GROUP
|
||||
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 group_label(group: str) -> str:
|
||||
"""상자 이름 — 이름표 `base_price_groups[갈래].name_ko` 에서만(없으면 영문 그대로 · 화면은 `group_label` 을 읽음)."""
|
||||
named = (tables.load_labels().get("base_price_groups") or {}).get(group) or {}
|
||||
return named.get("name_ko") or group
|
||||
|
||||
|
||||
def kinds() -> list[dict[str, Any]]:
|
||||
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
|
||||
|
||||
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"group": kind_group(kind),
|
||||
"group_label": group_label(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 in work_items.KINDS:
|
||||
return str(row.get("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")}
|
||||
|
||||
|
||||
#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」).
|
||||
#: ⚠ **찾기에만** 씀 — 열쇠·칸 값은 원문 그대로(손질하면 갈래 열쇠가 갈림 · 브레인 2026-09-17).
|
||||
_SEARCH_SAME = str.maketrans(
|
||||
{"∼": "~", "~": "~", "〜": "~", "․": "·", "ㆍ": "·", "・": "·", "‧": "·"}
|
||||
)
|
||||
|
||||
|
||||
def fold(text: str) -> str:
|
||||
"""찾기 비교용 — 소문자 · 물결표·가운뎃점 한 벌 · 공백 없앰."""
|
||||
return "".join(str(text or "").lower().translate(_SEARCH_SAME).split())
|
||||
|
||||
|
||||
def table(
|
||||
kind: str,
|
||||
page: int = 1,
|
||||
size: int = tables.DEFAULT_PAGE_SIZE,
|
||||
q: str = "",
|
||||
sort: str = "",
|
||||
desc: bool = False,
|
||||
filters: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
all_rows = rows(kind)
|
||||
columns = _columns(kind, all_rows)
|
||||
needle = fold(q)
|
||||
hits = (
|
||||
[
|
||||
r
|
||||
for r in all_rows
|
||||
if needle in fold(json.dumps(public(r), ensure_ascii=False, default=str))
|
||||
]
|
||||
if needle
|
||||
else all_rows
|
||||
)
|
||||
if kind in work_items.KINDS: # 서버가 준 거르기(부문·줄 구실) — 고른 값만 옴
|
||||
hits = work_items.apply_filters(kind, hits, filters or {})
|
||||
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),
|
||||
**(work_items.extra(kind) if kind in work_items.KINDS else {}),
|
||||
}
|
||||
|
||||
|
||||
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,217 @@
|
||||
"""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 = (
|
||||
"3_품셈_건설_기계경비기준_8장_2026-01-01.json",
|
||||
"3_품셈_건설_표전체_2026-01-01.json",
|
||||
"1_기초_노임_건설업_대한건설협회_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 / "master_data" / "old"
|
||||
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,415 @@
|
||||
"""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"]
|
||||
# ⚠ 원문은 **합판 한 줄에 두 열**(재료별% · 노무비%)이고 각재 줄엔 비율이 없다(2026-09-18).
|
||||
for material in ("plywood_material_pct", "plywood_labor_pct"):
|
||||
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"],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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, Request
|
||||
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
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
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,
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
size: int = tables.DEFAULT_PAGE_SIZE,
|
||||
q: str = "",
|
||||
sort: str = "",
|
||||
desc: int = 0,
|
||||
) -> dict:
|
||||
# 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실)
|
||||
picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS}
|
||||
try:
|
||||
return base_prices.table(
|
||||
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
|
||||
)
|
||||
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)}
|
||||
@@ -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 / "master_data" / "old"
|
||||
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("4_화면_이름표_*.json")) if LABEL_DIR.is_dir() else []
|
||||
if not found:
|
||||
return {"kinds": {}, "files": [], "columns": {}, "column_overrides": {}}
|
||||
return _labels_at(str(found[-1]), found[-1].stat().st_mtime_ns)
|
||||
|
||||
|
||||
def _file_path(entry: dict[str, Any]) -> Path | None:
|
||||
path = (ROOT / str(entry.get("path") or "")).resolve()
|
||||
return path if path.is_file() and path.is_relative_to(RESOURCES) else None
|
||||
|
||||
|
||||
def tree() -> dict[str, Any]:
|
||||
labels = load_labels()
|
||||
groups = {
|
||||
k: {"key": k, "label": v.get("name_ko") or k, "files": []}
|
||||
for k, v in labels["kinds"].items()
|
||||
}
|
||||
for entry in labels["files"]:
|
||||
path = _file_path(entry)
|
||||
if path is None:
|
||||
continue
|
||||
named = {t["key"]: t.get("name_ko") or "" for t in entry.get("tables") or []}
|
||||
named.update(
|
||||
{k: v.get("name_ko") or "" for k, v in (labels.get("synthetic_tables") or {}).items()}
|
||||
)
|
||||
kind = entry.get("kind") or ""
|
||||
group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []})
|
||||
counts = {tid: len(rows) for tid, rows in tables_of(path).items()}
|
||||
value_count = len(_value_rows(entry, path, labels))
|
||||
if value_count:
|
||||
counts[VALUES_TABLE] = value_count
|
||||
group["files"].append(
|
||||
{
|
||||
"id": entry["file_id"],
|
||||
"label": entry.get("name_ko") or entry["file_id"],
|
||||
"key": path.name,
|
||||
"tables": [
|
||||
{"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": n}
|
||||
for tid, n in counts.items()
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"groups": [g for g in groups.values() if g["files"]]}
|
||||
|
||||
|
||||
def rows(
|
||||
fid: str, tid: str, page: int = 1, size: int = DEFAULT_PAGE_SIZE, q: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
"""표 줄 한 쪽 — 없는 파일·표는 None. 검색은 줄 값 글자에 든 것(대소문자 무시)."""
|
||||
labels = load_labels()
|
||||
entry = next((f for f in labels["files"] if f["file_id"] == fid), None)
|
||||
path = _file_path(entry) if entry else None
|
||||
if path and tid == VALUES_TABLE:
|
||||
table = _value_rows(entry, path, labels) or None
|
||||
else:
|
||||
table = tables_of(path).get(tid) if path else None
|
||||
if table is None:
|
||||
return None
|
||||
own = (labels.get("synthetic_tables") or {}).get(tid) or next(
|
||||
(t for t in entry.get("tables") or [] if t.get("key") == tid), {}
|
||||
) # 낱값 표처럼 API 가 지은 표는 이름표 `synthetic_tables`
|
||||
own_columns = {c.get("key"): c for c in own.get("columns") or []}
|
||||
keys: dict[str, None] = {}
|
||||
for r in table:
|
||||
keys.update(dict.fromkeys(r))
|
||||
needle = q.strip().lower()
|
||||
hits = (
|
||||
[r for r in table if needle in json.dumps(r, ensure_ascii=False).lower()]
|
||||
if needle
|
||||
else table
|
||||
)
|
||||
size = max(1, min(size, MAX_PAGE_SIZE))
|
||||
start = (max(page, 1) - 1) * size
|
||||
columns = []
|
||||
for key in keys: # 찾는 차례: 그 표 열 목록(줄 이름 열 `@key` 는 표마다 제 이름) → 「파일id/열key」 → 「열key」 → 영문 key
|
||||
named = (
|
||||
own_columns.get(key)
|
||||
or labels["column_overrides"].get(f"{fid}/{key}")
|
||||
or labels["columns"].get(key)
|
||||
or {}
|
||||
)
|
||||
columns.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": named.get("name_ko") or key,
|
||||
"unit": named.get("unit") or "",
|
||||
"hidden": named.get("visible") is False,
|
||||
}
|
||||
)
|
||||
return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)}
|
||||
@@ -0,0 +1,269 @@
|
||||
/* =============================================================================
|
||||
* 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 (Array.isArray(value)) return value.map((each) => cellText(each, unit)).join(", ");
|
||||
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;
|
||||
/** 갈래 한글 이름(이름표 `base_price_groups`) — 안 오면 group 키가 그대로 떠 빠진 것이 드러남 */
|
||||
group_label?: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
type KindBox = { group: string; label: string; items: ServedKind[] };
|
||||
|
||||
/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */
|
||||
export function groupKinds(kinds: ServedKind[]): KindBox[] {
|
||||
const boxes: KindBox[] = [];
|
||||
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, label: kind.group_label ?? 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 !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 주는 거르기 한 벌(건설품셈 「임도가 쓰는 것」 따위) — 이름·선택지·줄 수·기본값 모두 서버 것.
|
||||
* 어느 줄을 거를지도 서버가 판정함 — 화면은 고른 값을 `key=value` 로 보내기만(2026-09-17 브레인).
|
||||
*/
|
||||
export interface TableFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
options: { value: string; label: string; rows?: number }[];
|
||||
/** 값을 안 보냈을 때 서버가 쓴 선택지 — 화면이 이것을 보여야 보이는 것과 걸린 것이 같음 */
|
||||
default: string;
|
||||
}
|
||||
|
||||
/** 거르기 상자에 보일 값 — 사람이 고른 것 · 안 골랐으면 서버가 이미 건 기본값. */
|
||||
export function filterValue(filter: TableFilter, picked: Record<string, string>): string {
|
||||
return picked[filter.key] ?? filter.default;
|
||||
}
|
||||
|
||||
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
|
||||
export interface TableMeta {
|
||||
filters?: TableFilter[];
|
||||
/** 한 표가 모은 원본마다 한 줄 */
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/* =============================================================================
|
||||
* Z01_MasterData_UI_Rows.ts
|
||||
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침
|
||||
*
|
||||
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침.
|
||||
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬
|
||||
* (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
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,
|
||||
filterValue,
|
||||
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,
|
||||
filters: {} as Record<string, string>,
|
||||
};
|
||||
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 filterBox = el("div", { className: "z01-master__toolbar" });
|
||||
const toolbar = el("div", {
|
||||
className: "z01-master__toolbar",
|
||||
children: [search.root, filterBox, 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,
|
||||
filters: state.filters,
|
||||
});
|
||||
} 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;
|
||||
// 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄).
|
||||
filterBox.replaceChildren(
|
||||
...(meta.filters ?? []).map(
|
||||
(filter) =>
|
||||
createSelectField({
|
||||
label: filter.label,
|
||||
options: filter.options.map((option) => ({
|
||||
value: option.value,
|
||||
text:
|
||||
option.rows == null
|
||||
? option.label
|
||||
: `${option.label} (${option.rows.toLocaleString("ko-KR")})`,
|
||||
})),
|
||||
value: filterValue(filter, state.filters),
|
||||
onChange: (value) => {
|
||||
state.filters[filter.key] = value;
|
||||
state.page = 1;
|
||||
void load();
|
||||
},
|
||||
}).root,
|
||||
),
|
||||
);
|
||||
// 숨긴 열이 없는 표(17 중 15)에서는 눌러도 아무 일이 없어 체크를 감춤.
|
||||
hiddenLabel.hidden = !meta.columns.some((column) => column.hidden);
|
||||
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;
|
||||
state.filters = {};
|
||||
filterBox.replaceChildren();
|
||||
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 };
|
||||
}
|
||||
@@ -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.label} › ${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 { label: box.label, 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.label, 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] });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) ·
|
||||
갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`.
|
||||
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침.
|
||||
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
|
||||
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
|
||||
`@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄.
|
||||
⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_Tables as tables
|
||||
|
||||
GROUP = "work_item"
|
||||
#: kind → 원본 품셈 dataset_id — 공종 축 파일 `dataset_version.dataset_id` 로 가림(파일 이름에 기대지 않음)
|
||||
SOURCE_OF = {
|
||||
"work_item_forest": "pum_forest",
|
||||
"work_item_const": "pum_const",
|
||||
} # 이름표 `work_item_axis` 와 같은 이름
|
||||
FOLDER = tables.RESOURCES / "master_data" / "old"
|
||||
#: 불변 열쇠 칸 — ⚠ 데스크탑 서브 자료가 오면 칸 이름을 맞출 것(여기 한 곳)
|
||||
KEY_FIELD = "work_item_key"
|
||||
PATH_JOIN = " › "
|
||||
_LOCKED = "공종 축 뼈대 — 품셈 원문으로만 바뀜(값 고치기는 원문 값 풀기 뒤)"
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def doc(kind: str) -> tuple[dict[str, Any], str] | None:
|
||||
"""그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음)."""
|
||||
found = []
|
||||
for path in sorted(FOLDER.glob("*work_item_master_*.json")): # 건설은 `const_` 앞꼬리
|
||||
d = _read(str(path), path.stat().st_mtime_ns)
|
||||
if (d.get("dataset_version") or {}).get("dataset_id") == SOURCE_OF[kind]:
|
||||
found.append((d, path.name))
|
||||
return max(found, key=lambda each: each[0].get("effective_date") or "", default=None)
|
||||
|
||||
|
||||
#: 자료가 있는 품셈만 kind 로 섬 — 빈 상자는 이름만 뜨고 안 열림 · 건설은 자료가 오면 서버 재시작 뒤 저절로
|
||||
KINDS = tuple(kind for kind in SOURCE_OF if doc(kind))
|
||||
|
||||
|
||||
def _key(item: dict[str, Any]) -> str:
|
||||
return item.get(KEY_FIELD) or item["work_item_code"]
|
||||
|
||||
|
||||
def _tree(d: dict[str, Any]) -> tuple[list[dict], dict[str, dict], dict[str, list[dict]]]:
|
||||
items = d.get("work_items") or []
|
||||
children: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
if item.get("parent_code"):
|
||||
children.setdefault(item["parent_code"], []).append(item)
|
||||
return items, {i["work_item_code"]: i for i in items}, children
|
||||
|
||||
|
||||
def _path(item: dict[str, Any] | None, by: dict[str, dict]) -> str:
|
||||
names = []
|
||||
while item:
|
||||
names.append(item["name"])
|
||||
item = by.get(item.get("parent_code"))
|
||||
return PATH_JOIN.join(reversed(names))
|
||||
|
||||
|
||||
def _shown_path(item: dict[str, Any], by: dict[str, dict]) -> str:
|
||||
"""목록에 보일 경로 — 조각마다 자간 공백 다듬기(「토목부문 › 측 량」→「측량」). 열쇠와 무관."""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing
|
||||
|
||||
return PATH_JOIN.join(tidy_spacing(part) for part in _path(item, by).split(PATH_JOIN))
|
||||
|
||||
|
||||
def base_rows(kind: str) -> list[dict[str, Any]]:
|
||||
found = doc(kind)
|
||||
if found is None:
|
||||
return []
|
||||
d, name = found
|
||||
items, by, children = _tree(d)
|
||||
# 계산 규칙 이름 — 이름표 `value_overrides["work_item_master::<방식>"]`(「아래를 단계로 다 더함」) · 없으면 영문
|
||||
named = tables.load_labels().get("value_overrides") or {}
|
||||
linked, roles = linked_keys(), _role_names()
|
||||
rows = []
|
||||
for item in items:
|
||||
if not item.get("tables"):
|
||||
continue
|
||||
parent = by.get(item.get("parent_code")) or {}
|
||||
weight = next(
|
||||
(s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]),
|
||||
None,
|
||||
)
|
||||
key = _key(item)
|
||||
# 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③).
|
||||
# 열쇠 = `공종 열쇠#갈래 번호`(`FW-00105#01`) — **갈래 글자를 열쇠에 담지 않는다.**
|
||||
# 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다(2026-09-17 브레인 · 장부가 번호를 준다).
|
||||
for variant in item.get("variants") or [None]:
|
||||
rows.append(
|
||||
{
|
||||
"@id": f"{key}#{variant['variant_key']}" if variant else key,
|
||||
"@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로
|
||||
"@source": name,
|
||||
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
|
||||
"number": item["number"],
|
||||
"name": _shown_path(item, by), # 자간 공백만 다듬음 — 원문 이름은 마스터에
|
||||
"variant": variant["name"] if variant else None, # 원문 그대로
|
||||
"variant_clean": variant["name_clean"] if variant else None, # 다듬은 것
|
||||
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
|
||||
"rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get(
|
||||
"name_ko"
|
||||
)
|
||||
or parent.get("parent_mode"),
|
||||
"step_weight": weight,
|
||||
# 「임도가 쓰는 것」 — 연결고리 표가 확정으로 이은 공종(판정은 그 표 · 감추지 않고 표시만)
|
||||
"link": LINKED if key in linked else "",
|
||||
# 줄 구실(총칙·공종) — 이름은 axis_policy.json · 표시 칸(거르기 상자로 고름)
|
||||
"axis_role": roles.get(str(item.get("axis_role") or ""), ""),
|
||||
"effective_date": d.get("pum_edition") or d.get("effective_date"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
LINK_FOLDER = tables.RESOURCES / "master_data" / "old"
|
||||
LINKED = "연결됨"
|
||||
|
||||
|
||||
def linked_keys() -> frozenset[str]:
|
||||
"""연결고리 표(`work_item_link_*.json` 끝 판 · 코덱스)가 **확정**으로 이은 열쇠(산림·건설).
|
||||
|
||||
⚠ 목록을 코드에 안 둠 — 판정은 그 표 · 미판정은 안 이음(표의 `unconfirmed_action: do_not_link`).
|
||||
"""
|
||||
found = sorted(LINK_FOLDER.glob("2_공종_산림건설_연결_*.json")) if LINK_FOLDER.is_dir() else []
|
||||
if not found:
|
||||
return frozenset()
|
||||
links = _read(str(found[-1]), found[-1].stat().st_mtime_ns).get("links") or []
|
||||
return frozenset(
|
||||
key
|
||||
for link in links
|
||||
if link.get("status") == "확정"
|
||||
for key in (link.get("forest_key"), link.get("const_key"))
|
||||
if key
|
||||
)
|
||||
|
||||
|
||||
def rules(kind: str) -> list[dict[str, Any]]:
|
||||
"""부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위)."""
|
||||
found = doc(kind)
|
||||
if found is None:
|
||||
return []
|
||||
items, by, children = _tree(found[0])
|
||||
out = []
|
||||
for item in items:
|
||||
mode = item.get("parent_mode")
|
||||
if not mode:
|
||||
continue
|
||||
rule = {
|
||||
"@id": _key(item),
|
||||
"work_item_code": item["work_item_code"],
|
||||
"name": _shown_path(item, by),
|
||||
"mode": mode,
|
||||
"children": [_key(c) for c in children.get(item["work_item_code"], [])],
|
||||
}
|
||||
if mode == "sum_steps":
|
||||
rule["steps"] = [
|
||||
{"@id": _key(by[s["code"]]), "weight": s["weight"]} for s in item["steps"]
|
||||
]
|
||||
rule["basis"] = item.get("steps_basis")
|
||||
out.append(rule)
|
||||
return out
|
||||
|
||||
|
||||
def spec(columns: list[str]) -> dict[str, Any]:
|
||||
return {"editable": [], "locked": dict.fromkeys(columns, _LOCKED), "formula": {}}
|
||||
|
||||
|
||||
def notice(kind: str) -> list[str]:
|
||||
lines = ["공종 축은 읽기만 — 고칠 칸 없음(품셈 원문 값 풀기 뒤에 열림)"]
|
||||
found = doc(kind)
|
||||
if found and any(KEY_FIELD not in i for i in found[0].get("work_items") or []):
|
||||
lines.append(
|
||||
"불변 열쇠(FW-·CW-) 아직 없음 — 목차 코드(FP-)를 열쇠로 씀 · 품셈 개정으로 번호가 밀리면 딴 줄을 가리킬 수 있음"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실 · 연결고리 표). 어느 줄을 거를지는 서버 한 곳 ·
|
||||
#: 화면은 고른 값만 보냄 · 연결은 기본 「전부」(감추지 않음 — 「어디에 쓸지 모르니 건설 전체」 · 브레인) ·
|
||||
#: 줄 구실만 기본 「공종」(총칙 기본 감춤 · 서브 2ee6a7ed · 브레인).
|
||||
FILTER_KEYS = ("link", "division", "axis_role")
|
||||
ALL = ""
|
||||
#: 값을 안 보냈을 때 거는 값 — **잣대가 정한다**(`axis_policy.json` 의 `list_default`).
|
||||
#: ⚠ 코드에 박지 않는다 — 총칙 범위는 기관이 정하는 것이라 관리자가 고칠 수 있어야 한다.
|
||||
#: 잣대를 못 읽을 때만 쓰는 대비값(총칙 줄은 기본 감춤 · 「전부」를 고르면 보임 · 2026-09-17 브레인).
|
||||
FALLBACK_DEFAULTS = {"axis_role": "work_item"}
|
||||
|
||||
|
||||
def _facets(kind: str) -> dict[str, dict[str, str]]:
|
||||
found = doc(kind)
|
||||
items = found[0].get("work_items") or [] if found else []
|
||||
linked = linked_keys()
|
||||
return {
|
||||
_key(i): {
|
||||
"link": LINKED if _key(i) in linked else ALL,
|
||||
"division": str(i.get("division") or ""),
|
||||
"axis_role": str(i.get("axis_role") or ""),
|
||||
}
|
||||
for i in items
|
||||
}
|
||||
|
||||
|
||||
def _policy() -> dict[str, Any]:
|
||||
"""줄 구실 잣대 — `axis_policy.json`(데스크탑 서브) 한 곳에서."""
|
||||
path = FOLDER / "axis_policy.json"
|
||||
return _read(str(path), path.stat().st_mtime_ns) if path.is_file() else {}
|
||||
|
||||
|
||||
def _role_names() -> dict[str, str]:
|
||||
"""거르기 상자에 뜰 이름. 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름으로 적는다."""
|
||||
roles = _policy().get("roles") or {}
|
||||
return {
|
||||
key: str(role.get("list_label") or role.get("name_ko") or key)
|
||||
for key, role in roles.items()
|
||||
}
|
||||
|
||||
|
||||
def _defaults() -> dict[str, str]:
|
||||
"""안 고른 가름에 걸 값 — 잣대의 `list_default`. 못 읽으면 대비값."""
|
||||
wanted = _policy().get("list_default") or {}
|
||||
key = str(wanted.get("filter") or "")
|
||||
return {key: str(wanted.get("value") or ALL)} if key else dict(FALLBACK_DEFAULTS)
|
||||
|
||||
|
||||
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 **잣대**(`list_default`)가 정한다 — 그 값이 표에 있을 때만."""
|
||||
facets = _facets(kind)
|
||||
labels = {
|
||||
"link": ("연결", {LINKED: "연결된 것만"}),
|
||||
"division": ("부문", {}),
|
||||
"axis_role": ("줄 구실", _role_names()),
|
||||
}
|
||||
out = []
|
||||
for key in FILTER_KEYS:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
value = facets.get(row["@key"], {}).get(key, ALL)
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
values = [v for v in counts if v != ALL]
|
||||
if len(counts) < 2: # 다 같은 값이면 거를 것이 없음(연결은 「연결됨」 · 빈칸 둘로 갈림)
|
||||
continue
|
||||
label, names = labels[key]
|
||||
default = _defaults().get(key, ALL)
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"options": [{"value": ALL, "label": "전부", "rows": len(rows)}]
|
||||
+ [{"value": v, "label": names.get(v, v), "rows": counts[v]} for v in values],
|
||||
"default": default if default in values else ALL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def apply_filters(
|
||||
kind: str, rows: list[dict[str, Any]], picked: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
facets = _facets(kind)
|
||||
# 안 보낸 가름은 상자 기본값으로 — 상자에 보이는 값과 걸린 값이 같게(찾기 결과가 아니라 표 전체로 정함)
|
||||
defaults = {f["key"]: f["default"] for f in filters(kind, base_rows(kind))}
|
||||
for key in FILTER_KEYS:
|
||||
value = picked.get(key, defaults.get(key, ALL))
|
||||
if value != ALL:
|
||||
rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value]
|
||||
return rows
|
||||
|
||||
|
||||
def extra(kind: str) -> dict[str, Any]:
|
||||
"""표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체)."""
|
||||
rows = base_rows(kind)
|
||||
return {
|
||||
"filters": filters(kind, rows),
|
||||
"rules": rules(kind),
|
||||
"stats": {
|
||||
"work_items": len({r["@key"] for r in rows}),
|
||||
"units": len(rows),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
"""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)
|
||||
# 2026-09-18 218 → 220 — 전문공사 일반관리비 300억~1000억·1000억 이상 두 구간이 빠져 있던 것을 채움
|
||||
assert rate["total"] == 220 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
|
||||
)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""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
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
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 + work_items.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
|
||||
# ⚠ 지금 상태를 먼저 — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 브레인 「없으면 없다고」)
|
||||
assert any(line.startswith("⚠ **B06 횡단설계가 아직") for line in slope)
|
||||
assert slope[0].startswith("⚠ **아직 어디에도 안 쓰임**")
|
||||
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_material_pct/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_material_pct/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", "work_item"}
|
||||
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"
|
||||
) # 이름표 전엔 영문
|
||||
@@ -0,0 +1,351 @@
|
||||
"""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, filterValue, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
|
||||
} from "./Z01_MasterData_UI_Cells.js";
|
||||
|
||||
// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인)
|
||||
const served = [
|
||||
{ group: "base_price", group_label: "기초단가", kind: "labor", label: "노임", rows: 261 },
|
||||
{ group: "base_price", group_label: "기초단가", kind: "oil", label: "유가", rows: 36 },
|
||||
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "coef", label: "토량환산계수", rows: 32 },
|
||||
// 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움
|
||||
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
|
||||
{ group: "work_axis", group_label: "공종 축", kind: "forest", label: "산림품셈 기준", rows: 358 },
|
||||
// 갈래 이름이 안 오면 키가 그대로 떠 빠진 것이 드러남(때우지 않음)
|
||||
{ group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 },
|
||||
];
|
||||
const grouped = groupKinds(served).map((g) => [g.label, g.items.map((i) => i.kind)]);
|
||||
|
||||
// 거르기 — 안 골랐으면 서버가 이미 건 기본값을 보임(보이는 것 = 걸린 것) · 고르면 고른 값
|
||||
const roadFilter = {
|
||||
key: "use",
|
||||
label: "보기",
|
||||
options: [{ value: "all", label: "전부", rows: 995 }, { value: "road", label: "임도가 쓰는 것", rows: 120 }],
|
||||
default: "road",
|
||||
};
|
||||
const filtered = [filterValue(roadFilter, {}), filterValue(roadFilter, { use: "all" })];
|
||||
|
||||
// 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터
|
||||
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 (화면이 original≠current 로 따로 안 셈)
|
||||
const withState = (state, current) => ({
|
||||
"@id": "M-002",
|
||||
daily_wage_krw: 300000,
|
||||
"@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } },
|
||||
});
|
||||
// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited
|
||||
const changedRow = withState("source_changed", 250000);
|
||||
const sameRow = withState("edited", 270000);
|
||||
const orphanRow = withState("orphan", undefined);
|
||||
// 뜻 바뀜 — 번호는 그대로인데 줄이 딴 물건이 됨(노임 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),
|
||||
// 공종 축 목록 칸 — 괄호·따옴표 글자로 뜨던 것(2026-09-17 브레인 ④)
|
||||
cellText(["연암", "보통암", "경암"], null),
|
||||
cellText([], null),
|
||||
],
|
||||
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
|
||||
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
|
||||
clamp: [clampPage(801, 800), clampPage(0, 800), clampPage(NaN, 800), clampPage(12.6, 800)],
|
||||
race: race.map((v) => v ?? null),
|
||||
raceError: raceError.map((v) => v ?? null),
|
||||
latestError,
|
||||
info: [
|
||||
cellInfo(meta, machineRow, "daily_wage_krw"),
|
||||
cellInfo(meta, machineRow, "hourly_krw"),
|
||||
cellInfo({}, { name: "x" }, "name"),
|
||||
cellInfo(meta, changedRow, "daily_wage_krw"),
|
||||
cellInfo(meta, sameRow, "daily_wage_krw"),
|
||||
cellInfo(meta, orphanRow, "daily_wage_krw"),
|
||||
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,
|
||||
filtered,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
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},
|
||||
]
|
||||
# 서버 차례 그대로 갈래별로 묶임 · 상자 이름은 서버 group_label · 새 kind·새 갈래도 저절로 섬
|
||||
assert got["grouped"] == [
|
||||
["기초단가", ["labor", "oil"]],
|
||||
["품셈 기준", ["coef", "machine_productivity"]],
|
||||
["공종 축", ["forest"]],
|
||||
["later_group", ["brand_new"]],
|
||||
]
|
||||
# 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸)
|
||||
assert got["filtered"] == ["road", "all"]
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판.
|
||||
|
||||
약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 ·
|
||||
표 = 화면이 표로 그릴 마디(이름표 92 이 정본) · ⭐ 트리 표 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 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다.
|
||||
# 2026-09-17 공종 축 산출 일곱을 올림 — logic 15 → 19 · byproduct 4 → 6 · fingerprint 3 → 4
|
||||
assert counts == {
|
||||
"logic": 19,
|
||||
"base_value": 9,
|
||||
"byproduct": 6,
|
||||
"reference": 2,
|
||||
"fingerprint": 4,
|
||||
"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
|
||||
# 2026-09-17 92 → 101 — 공종 축 산출 일곱 + 갈래 열쇠 장부 표.
|
||||
# 2026-09-17 101 → 102 — 건설 열쇠 장부에도 갈래 표(건설 갈래 펴기 ④).
|
||||
# 2026-09-17 102 → 101 — 산림 형태 미판정이 0 이 되어 그 표가 빠짐(값 묶음으로 내려감).
|
||||
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 101
|
||||
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
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
|
||||
불변 열쇠가 `@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_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
|
||||
@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 test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
|
||||
listed = client.get("/api/master-data/base-prices").json()["kinds"]
|
||||
forest = next(i for i in listed if i["kind"] == "work_item_forest")
|
||||
assert (
|
||||
forest["group"] == "work_item" and forest["rows"] == 624
|
||||
) # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
|
||||
# 2026-09-18 합침 620 → 624 — 원문이 풀리며 6-7-1 교목 시비 갈래 다섯이 새로 섬(사라진 것 0)
|
||||
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
|
||||
assert {i["group"]: i["group_label"] for i in listed} == {
|
||||
"base_price": "기초단가",
|
||||
"pumsem_basis": "품셈 기준",
|
||||
"work_item": "공종 축",
|
||||
}
|
||||
|
||||
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
|
||||
assert table["total"] == 624 and table["stats"] == {"work_items": 360, "units": 624}
|
||||
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
|
||||
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
|
||||
assert len({r["@id"] for r in rows}) == 624
|
||||
assert (
|
||||
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360
|
||||
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
|
||||
by_code = {r["work_item_code"]: r for r in rows}
|
||||
assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계"
|
||||
|
||||
modes = [r["mode"] for r in table["rules"]]
|
||||
assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3
|
||||
blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05")
|
||||
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
|
||||
# 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②)
|
||||
assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함"
|
||||
assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름"
|
||||
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
|
||||
# 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거.
|
||||
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
|
||||
assert not any("불변 열쇠" in line for line in table["notice"])
|
||||
|
||||
found = _get(client, "work_item_forest", q="발파암 ›")
|
||||
assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토
|
||||
|
||||
|
||||
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
|
||||
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.
|
||||
갈래도 산림과 같은 표 읽기로 폄(④ · 공종 997 → 줄 1,293 · 갈래 열쇠 `CW-00063#01`)."""
|
||||
table = _get(client, "work_item_const", size=500, axis_role="")
|
||||
# 2026-09-18 997/1,293 그대로 — 앞서 991/1,287 로 보였던 것은 **산출 파일이 옛 것**이어서였다
|
||||
# (기계 다리가 선 뒤 다시 지으니 제자리로 돌아옴).
|
||||
# 2026-09-18 랩탑 메인 — 건설 표 모양 판정층으로 미판정 891 → 276 이 되며 품 표가 늘어 줄도 늘었다.
|
||||
# 2026-09-18 합침 — 다른 창이 원문 md 의 뭉친 줄·표를 더 풀어(건설 표 2,202 → 2,270) 공종·줄이 또 늘었다.
|
||||
# 값이 바뀐 것이 아니라 **원문에 있던 표가 더 읽힌 것**이다(직접공사비 5,983,724 그대로).
|
||||
assert table["stats"] == {"work_items": 1046, "units": 1503} and table["total"] == 1503
|
||||
assert sum(1 for r in table["rows"] if "#" in r["@id"]) > 0
|
||||
rows = table["rows"]
|
||||
assert all(r["@id"].startswith("CW-") for r in rows)
|
||||
assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows)
|
||||
assert {r["mode"] for r in table["rules"]} == {"choose_one"}
|
||||
assert not any("불변 열쇠" in line for line in table["notice"])
|
||||
found = _get(client, "work_item_const", q="굴착(인력/토사)")
|
||||
assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"]
|
||||
|
||||
|
||||
def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
|
||||
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
folder = tmp_path / "data_work_item_master"
|
||||
folder.mkdir()
|
||||
fake = {
|
||||
"dataset_version": {"dataset_id": "pum_const"},
|
||||
"effective_date": "2026-01-01",
|
||||
"work_items": [
|
||||
{"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1",
|
||||
"name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps",
|
||||
"steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []},
|
||||
{"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1",
|
||||
"name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}],
|
||||
"variant_keys": ["갑", "을"],
|
||||
"variants": [{"variant_key": "01", "name": "갑", "name_clean": "갑"},
|
||||
{"variant_key": "02", "name": "을", "name_clean": "을"}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
(folder / "const_work_item_master_2026-01-01.json").write_text(
|
||||
json.dumps(fake, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(work_items, "FOLDER", folder)
|
||||
|
||||
table = _get(client, "work_item_const")
|
||||
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
|
||||
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
|
||||
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
|
||||
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
|
||||
assert table["rows"][0]["name"] == "토공 › 인력"
|
||||
assert table["stats"] == {"work_items": 1, "units": 2}
|
||||
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
|
||||
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
|
||||
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
|
||||
json={"values": {"name": "바꿈"}},
|
||||
)
|
||||
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
|
||||
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
|
||||
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표)."""
|
||||
const = _get(client, "work_item_const", size=1, axis_role="")
|
||||
by_key = {f["key"]: f for f in const["filters"]}
|
||||
assert set(by_key) == {"link", "division", "axis_role"}
|
||||
division = by_key["division"]
|
||||
assert division["default"] == ""
|
||||
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
|
||||
assert [o["value"] for o in division["options"][1:]] == [
|
||||
"공통부문",
|
||||
"토목부문",
|
||||
"건축부문",
|
||||
"기계설비부문",
|
||||
"유지관리부문",
|
||||
]
|
||||
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
|
||||
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`).
|
||||
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
|
||||
"general_provision"
|
||||
] == "공종 아님 · 규정·기준"
|
||||
assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아님
|
||||
|
||||
civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="")
|
||||
assert civil["total"] == next(
|
||||
o["rows"] for o in division["options"] if o["value"] == "토목부문"
|
||||
)
|
||||
assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"])
|
||||
rules = _get(
|
||||
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
|
||||
)
|
||||
assert rules["total"] > 0
|
||||
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
|
||||
assert all(
|
||||
r["name"].startswith(("공통부문 › 적용기준", "공통부문 › 건설기계")) for r in rules["rows"]
|
||||
)
|
||||
role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}
|
||||
# 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳
|
||||
assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]}
|
||||
|
||||
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
|
||||
"link",
|
||||
"axis_role",
|
||||
]
|
||||
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
|
||||
|
||||
|
||||
def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None:
|
||||
"""브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄.
|
||||
기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음)."""
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
link_doc = json.loads(
|
||||
sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8")
|
||||
)
|
||||
confirmed = {
|
||||
key
|
||||
for link in link_doc["links"]
|
||||
if link["status"] == "확정"
|
||||
for key in (link["forest_key"], link["const_key"])
|
||||
if key
|
||||
}
|
||||
unconfirmed = {
|
||||
key
|
||||
for link in link_doc["links"]
|
||||
if link["status"] != "확정"
|
||||
for key in (link["forest_key"], link["const_key"])
|
||||
if key
|
||||
} - confirmed
|
||||
for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")):
|
||||
every = _get(client, kind, size=500, axis_role="")
|
||||
rows = [
|
||||
row
|
||||
for page in range(1, every["total"] // 500 + 2)
|
||||
for row in _get(client, kind, size=500, page=page, axis_role="")["rows"]
|
||||
]
|
||||
assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임
|
||||
marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로
|
||||
listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖
|
||||
assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed}
|
||||
assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상
|
||||
assert not marked & unconfirmed
|
||||
only = _get(client, kind, size=500, link="연결됨", axis_role="")
|
||||
assert {r["@key"] for r in only["rows"]} == marked
|
||||
box = next(f for f in every["filters"] if f["key"] == "link")
|
||||
assert box["default"] == "" and box["options"][1]["rows"] == only["total"]
|
||||
|
||||
|
||||
def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None:
|
||||
"""2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름."""
|
||||
rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"]
|
||||
assert [r["variant"] for r in rows] == [
|
||||
"1.0 이하", "1.1∼1.5", "1.6∼2.0", "2.1∼2.5", "2.6∼3.0",
|
||||
"3.1∼3.5", "3.6∼4.0", "4.1∼4.5", "4.6∼5.0",
|
||||
] # fmt: skip
|
||||
# 열쇠에 갈래 **글자**를 담지 않는다 — 원문 물결표·자간이 다듬어지면 열쇠가 갈린다(브레인).
|
||||
assert rows[1]["@id"] == "FW-00105#02" and rows[1]["@key"] == "FW-00105"
|
||||
assert rows[1]["variant"] == "1.1∼1.5" # 글자는 보일 이름 칸으로
|
||||
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
|
||||
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
|
||||
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]
|
||||
# 갈래 이름으로도 찾힘
|
||||
assert _get(client, "work_item_forest", q="2.1∼2.5")["total"] >= 1
|
||||
|
||||
|
||||
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
|
||||
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
|
||||
for kind in ("work_item_forest", "work_item_const"):
|
||||
shown = _get(client, kind, size=500)
|
||||
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
|
||||
assert role["default"] == "work_item"
|
||||
counts = {o["value"]: o["rows"] for o in role["options"]}
|
||||
assert counts["general_provision"] > 0
|
||||
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
|
||||
assert shown["total"] == counts["work_item"]
|
||||
assert (
|
||||
_get(client, kind, size=500, q="적용기준")["total"]
|
||||
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
|
||||
)
|
||||
# 「전부」(빈 값을 보냄) → 총칙까지
|
||||
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
|
||||
assert (
|
||||
_get(client, kind, size=1, axis_role="general_provision")["total"]
|
||||
== (counts["general_provision"])
|
||||
)
|
||||
|
||||
|
||||
def test_찾기는_물결표_가운뎃점_띄어쓰기를_같게_봄(client: TestClient) -> None:
|
||||
"""원문이 물결표를 둘(∼·~)로 쓰고 자간 공백이 들쭉날쭉 — 사람이 친 「~」·붙여 쓴 이름으로도 찾힘.
|
||||
⚠ 찾기만 — 줄 값·열쇠는 원문 그대로(서브 훑기 · 브레인 2026-09-17)."""
|
||||
wavy = _get(client, "work_item_forest", size=50, q="2.1∼2.5")["total"]
|
||||
assert wavy >= 1
|
||||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||||
rows = _get(client, "work_item_forest", size=50, q="2.1~2.5")["rows"]
|
||||
assert all("~" not in r["@id"] for r in rows) # 열쇠는 손질 안 함
|
||||
spaced = _get(client, "work_item_forest", size=50, q="토사 깍기", axis_role="")["total"]
|
||||
assert (
|
||||
spaced == _get(client, "work_item_forest", size=50, q="토사깍기", axis_role="")["total"] > 0
|
||||
)
|
||||
|
||||
|
||||
def test_이름_다듬기는_보일_이름만_열쇠는_그대로(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""⭐ 갈래 불변 열쇠를 세운 까닭(브레인 2026-09-17) — 자간 공백(「측 량」·「평 규 준 틀」)을 다듬어도
|
||||
줄 열쇠(`FW-…#01`)가 한 글자도 안 흔들림. 다듬기를 끈 목록과 켠 목록의 열쇠를 맞댐."""
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Keys as keys
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
for kind in work_items.KINDS:
|
||||
tidy = work_items.base_rows(kind)
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(keys, "tidy_spacing", lambda text: " ".join(str(text or "").split()))
|
||||
raw = work_items.base_rows(kind)
|
||||
assert [r["@id"] for r in tidy] == [r["@id"] for r in raw]
|
||||
if kind == "work_item_const":
|
||||
assert (
|
||||
sum(a["name"] != b["name"] for a, b in zip(tidy, raw)) > 0
|
||||
) # 실제로 다듬어진 줄이 있음
|
||||
shown = _get(client, "work_item_const", size=50, q="기준점 측량", axis_role="")["rows"]
|
||||
assert shown and all("측 량" not in r["name"] and "› 측량 ›" in r["name"] for r in shown)
|
||||
|
||||
|
||||
def test_품셈_번호는_마디마다_수로_세움(client: TestClient) -> None:
|
||||
"""글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기) — 마디마다 수로."""
|
||||
rows = _get(client, "work_item_forest", size=500, sort="number", axis_role="")["rows"]
|
||||
numbers = [tuple(int(x) for x in r["number"].split("-")) for r in rows if "-" in r["number"]]
|
||||
assert len(numbers) > 300 and numbers == sorted(numbers)
|
||||
|
||||
|
||||
def test_건설_늘_빈_칸은_감춤(client: TestClient) -> None:
|
||||
"""건설은 합산형 선언이 없어 「단계 무게」가 늘 빈칸 — 감춤(산림은 보임)."""
|
||||
hidden = lambda kind: {c["key"]: c["hidden"] for c in _get(client, kind, size=1)["columns"]} # noqa: E731
|
||||
assert hidden("work_item_const")["step_weight"] is True
|
||||
assert hidden("work_item_forest")["step_weight"] is False
|
||||
Reference in New Issue
Block a user