revert(Z01): 롤백된 마스터 DB·화면 작업이 dev 로 되들어온 것 걷어냄

- 사용자 「완전히 되돌려」 뒤 dev 를 거쳐 다시 들어온 1-2·1-3·1-4 코드 26 경로를 9629430a 모습으로(고친 17 되돌림 · 새로 생긴 9 지움)
- 대상: Z01_MasterData 17 · main.py · ui_template_locale_b3.ts · resources/tester 의 Z01 시험 7
- 품셈 md·도구는 안 건드림 · 이력 재작성 없음(되돌림 커밋)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
This commit is contained in:
2026-09-19 01:36:21 +09:00
co-authored by Claude Opus 5
parent 6b417d3b79
commit f2fe4f4214
26 changed files with 1012 additions and 3598 deletions
+37 -159
View File
@@ -2,12 +2,17 @@
* Z01_MasterData_Api_Fetch.ts
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
*
* 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄
* (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐).
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells";
import type {
MasterColumn,
OverrideState,
ServedKind,
SortOrder,
TableMeta,
} from "./Z01_MasterData_UI_Cells";
export interface MasterTable {
id: string;
@@ -33,16 +38,11 @@ export interface MasterGroup {
files: MasterFile[];
}
/**
* 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴.
* 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음.
*/
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
export interface MasterRows extends TableMeta {
columns: MasterColumn[];
rows: Record<string, unknown>[];
total: number;
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
revision?: number;
}
export interface RowsQuery {
@@ -65,34 +65,17 @@ export function fetchBaseKinds(): Promise<ServedKind[]> {
return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds);
}
/** 실패 응답 — `status` 409 = 낡은 판(다른 곳에서 먼저 저장함). */
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
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().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) {
// 검사 실패(422)는 detail 이 목록으로 옴 — 글자로 펴서 보임(「[object Object]」 금지).
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
throw new ApiError(detail ?? "Request failed", response.status);
}
const data = (await response.json()) as { detail?: string } & T;
if (!response.ok) throw new Error(data.detail ?? "Request failed");
return data;
}
const send = <T>(method: string, path: string, body: unknown): Promise<T> =>
request<T>(path, { method, body: JSON.stringify(body) });
const pageParams = (query: RowsQuery): Record<string, string> => ({
page: String(query.page),
size: String(query.size),
@@ -110,142 +93,37 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
}
/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */
export interface BaseSave {
base_revision: number;
edits: { row_key: string; column: string; value: unknown }[];
added: { data: Record<string, unknown> }[];
deleted: string[];
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;
}
export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise<unknown> {
return send("PUT", `/master-data/base-prices/${kind}`, body);
/** 고친 것 모아 보기 — 서버가 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}`);
}
/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */
export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise<unknown> {
return send("POST", `/master-data/base-prices/${kind}/reset`, { row_keys: rowKeys });
}
/* ── 공종 마스터(PLAN 1-0) ── */
export type WorkItemAxis = "forest" | "const";
/** 주입 때 한 번 판정 · `reviewed` 는 관리자만 찍음 */
export type WorkItemStatus = "auto_ok" | "partial" | "needs_input" | "not_item" | "reviewed";
export type ResourceKind = "labor" | "machine" | "material" | "etc";
export interface WorkItemListRow {
work_item_key: string;
number: string;
path_name: string;
status: WorkItemStatus;
row_count: number;
}
export interface WorkItemList {
items: WorkItemListRow[];
total: number;
/** 상태별 개수(검색·상태 거르기 전 그 축 전체) */
counts: Partial<Record<WorkItemStatus, number>>;
}
export interface WorkItemListQuery {
axis: WorkItemAxis;
status: WorkItemStatus | "";
q: string;
page: number;
size: number;
}
export function fetchWorkItems(query: WorkItemListQuery): Promise<WorkItemList> {
const params = new URLSearchParams({
axis: query.axis,
q: query.q,
page: String(query.page),
size: String(query.size),
/** 칸 고치기 — 값 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 }),
});
if (query.status) params.set("status", query.status);
return request<WorkItemList>(`/master-data/work-items?${params}`);
}
/** 머리 중 관리자가 고치는 칸 — PUT 에 이것만 보냄 */
export interface WorkItemHead {
name: string;
basis_quantity: number | null;
basis_unit: string | null;
status: WorkItemStatus;
}
export interface WorkItemItem extends WorkItemHead {
work_item_key: string;
axis: WorkItemAxis;
code: string | null;
number: string;
path_name: string;
parent_key: string | null;
parent_mode: string | null;
edition: string | null;
revision: number;
/** 머리 중 초기값과 다른 칸(저장된 고침) */
changed_columns?: string[];
}
/** 구성 줄 — id 없으면 새 줄 · resource_key 없으면 기초단가와 못 맞춘 줄(raw_cell 에 원문과 까닭) */
export interface WorkItemRow {
id?: number;
variant: string;
resource_kind: ResourceKind;
resource_key: string | null;
resource_name: string;
resource_spec: string;
amount: number | null;
amount_unit: string;
alternative_amount: number | null;
group_ratio_pct: number | null;
raw_cell: string | null;
pum_table_id: string | null;
/** GET 만 — 관리자가 넣은 줄 · 초기값과 다른 칸 */
is_added?: boolean;
changed_columns?: string[];
}
/** 품셈 원문 표 한 벌 — condition_note = 머리 글자 · raw_row = 원문 칸(읽기 전용) */
export interface WorkItemSource {
pum_table_id: string;
section?: string | null;
pum_form?: string | null;
condition_note?: unknown;
raw_row?: unknown;
}
export interface WorkItemDetail {
item: WorkItemItem;
rows: WorkItemRow[];
source: WorkItemSource[];
notes: unknown[];
}
/** 공종 [저장] — rows 는 전체 줄(id 있으면 수정 · 없으면 추가 · 빠지면 삭제) · 낡은 판이면 409 */
export interface WorkItemSave {
base_revision: number;
item: WorkItemHead;
rows: WorkItemRow[];
}
const workItemPath = (key: string) => `/master-data/work-items/${encodeURIComponent(key)}`;
export function fetchWorkItem(key: string): Promise<WorkItemDetail> {
return request<WorkItemDetail>(workItemPath(key));
}
/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */
export function saveWorkItem(key: string, body: WorkItemSave): Promise<WorkItemDetail> {
return send("PUT", workItemPath(key), body);
}
/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */
export function resetWorkItem(key: string): Promise<WorkItemDetail> {
return send("POST", `${workItemPath(key)}/reset`, {});
}
export async function fetchMasterTree(): Promise<MasterGroup[]> {
+111 -158
View File
@@ -1,20 +1,16 @@
"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약).
표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만)
줄 수준: `@id`(자료 제 열쇠) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자 추가 줄) · `@formula`(계산값 줄)
줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄)
`@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code ·
자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]`(원본 값 그대로 · 열 이름은 이름표 것) · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌)
= DB `master_base_price`(2026-09-18 PLAN 1-0 · `Z01_MasterData_Repository_BasePrices`) — 파일 원본(`base_rows`)
초기값 1회 주입(`Z01_MasterData_Seed`)의 재료 · 공종 축 둘만 아직 파일 읽기.
파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`).
"""
from __future__ import annotations
import asyncio
import json
import math
import re
import uuid
from datetime import date
from decimal import Decimal
from functools import lru_cache
@@ -23,7 +19,7 @@ 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_Repository_BasePrices as repo
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
@@ -32,7 +28,7 @@ KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work
SOURCES_DIR = (
tables.RESOURCES / "data_master_sources"
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
_KEY = "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음"
_KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
_SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜"
_RATE_EDITABLE = (
"rate_percent",
@@ -43,14 +39,14 @@ _RATE_EDITABLE = (
"manager_thresholds.default_estimated_amount_krw",
"manager_thresholds.civil_main_work_estimated_amount_krw",
)
#: ⚠ **지금 상태를 먼저** — 고친 값을 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠.
_SCOPE_NOTICE = (
"⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음."
" 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
"(프로젝트는 만들 때 뜬 사본을 봄)"
)
_RATE_NOTICE = "법이 정한 값 — 고시가 바뀔 때만 고칠 것(구간 칸은 규칙이라 못 고침)"
_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)"
def _source(file_id: str) -> Path:
@@ -281,7 +277,7 @@ def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
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))
@@ -294,67 +290,33 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
return _BUILDERS[kind]()
def _machine_inputs(labor: list[dict[str, Any]], oil: list[dict[str, Any]]) -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — DB 의 노임(건설)·전국평균 유가 + 그 판들의 기준일(빈 값 줄은 뺌)."""
labor = [r for r in labor if r.get("source") == "labor_const" and r.get("occupation_code")]
def _machine_inputs() -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일."""
labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"]
oil = [
r
for r in oil
if r["@id"] == f"national_average/{r.get('fuel')}" and r.get("price_krw_per_l") is not None
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.get("daily_wage_krw") is None
if r["daily_wage_krw"] is None
else Decimal(str(r["daily_wage_krw"]))
for r in labor
},
wage_date=labor[0].get("effective_date") if labor else None,
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.get("effective_date") 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 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 _row(record: dict[str, Any]) -> dict[str, Any]:
"""DB 한 줄 → 표 줄. `@data` = 저장된 값 그대로(쓸 때 여기에 고친 칸만 얹음 · 기계 계산 칸이 섞이지 않게)."""
data, seed = record["data"], record["seed"]
changed = []
if seed is not None:
changed = [
c
for c in dict.fromkeys([*seed, *data])
if not c.startswith("@") and not same(data.get(c), seed.get(c))
]
row = {"@id": record["row_key"], **data}
row.update(
{"@id": record["row_key"], "@data": data, "@added": seed is None, "@changed": changed}
)
return row
async def _stored(kind: str) -> list[dict[str, Any]]:
return [_row(r) for r in await repo.load(kind)]
async def rows(kind: str) -> list[dict[str, Any]]:
"""DB 정본 줄(공종 축 둘만 파일) — 기계는 DB 노임·유가로 시간당 단가를 읽을 때마다 셈(식은 코드 · 브레인)."""
if kind in work_items.KINDS:
return work_items.base_rows(kind)
if kind != "machine":
return await _stored(kind)
out, labor, oil = await asyncio.gather(_stored("machine"), _stored("labor"), _stored("oil"))
inputs = _machine_inputs(labor, oil)
return [machine.compute(r, inputs) for r in out]
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]:
@@ -484,26 +446,45 @@ def group_label(group: str) -> str:
return named.get("name_ko") or group
async def kinds() -> list[dict[str, Any]]:
def kinds() -> list[dict[str, Any]]:
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수(DB · 공종 축은 파일).
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
"""
counted = await repo.counts()
return [
{
"group": kind_group(kind),
"group_label": group_label(kind_group(kind)),
"kind": kind,
"label": kind_label(kind),
"rows": len(work_items.base_rows(kind))
if kind in work_items.KINDS
else counted.get(kind, 0),
"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()
@@ -520,12 +501,27 @@ def column_meta(kind: str, key: str) -> dict[str, Any]:
}
_INNER = ("@source", "@axis", "@data", "@added", "@changed")
#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨
_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`·`@data`…)은 뺌."""
return {k: v for k, v in row.items() if k not in _INNER}
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌."""
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」).
@@ -542,7 +538,6 @@ def fold(text: str) -> str:
def table(
kind: str,
all_rows: list[dict[str, Any]],
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
q: str = "",
@@ -550,7 +545,7 @@ def table(
desc: bool = False,
filters: dict[str, str] | None = None,
) -> dict[str, Any]:
"""한 표 한 쪽 — `all_rows` 는 `rows(kind)` 가 준 전체(DB 읽기와 셈을 떼어 둠)."""
all_rows = rows(kind)
columns = _columns(kind, all_rows)
needle = fold(q)
hits = (
@@ -574,14 +569,7 @@ def table(
"columns": [column_meta(kind, c) for c in columns],
# 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림
"sortable": list(columns),
"rows": [
{
**public(r),
"changed_columns": r.get("@changed", []),
"is_added": r.get("@added", False),
}
for r in hits[start : start + size]
],
"rows": [public(r) for r in hits[start : start + size]],
"total": len(hits),
**spec(kind, columns),
"notice": notice(kind),
@@ -590,84 +578,49 @@ def table(
}
class SaveError(ValueError):
"""[저장] 을 막는 까닭 — 하나라도 있으면 아무것도 안 씀(전부 아니면 전무)."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
def check_value(value: Any) -> str | None:
"""값 칸에 넣을 수 있나 — 막는 까닭(없으면 None). null 은 빈 값(미공표 노임처럼)."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "숫자만 넣을 수 있음"
if not math.isfinite(value) or value < 0:
return "0 이상 유한한 수만 넣을 수 있음"
return None
def _axis_of(kind: str) -> dict[str, tuple[str, ...]]:
"""줄마다 축 칸 — 파일 원본에서(표 모양이라 값 고치기로 안 바뀜 · 주입 data 에 `@axis` 가 없어도 섬)."""
if kind not in base_tables.SPEC:
return {}
return {r["@id"]: tuple(r.get("@axis") or ()) for r in base_rows(kind)}
def _cell_problem(
row: dict[str, Any], column: str, value: Any, info: dict[str, Any], axis: dict, fillable: set
) -> str | None:
"""한 칸 고치기를 막는 까닭 — 추가 줄은 저장 칸이면 글자 칸도(이름·규격을 사람이 넣은 줄)."""
if row["@added"]:
if column not in fillable:
return f"못 고치는 칸 {column}{info['locked'].get(column) or '이 표에 없는 칸'}"
elif column in axis.get(row["@id"], ()):
return f"못 고치는 칸 {column}{_KEY}"
elif column not in info["editable"] or column not in row:
return f"못 고치는 칸 {column}{info['locked'].get(column) or '이 줄에 없는 칸'}"
if column in info["editable"]:
problem = check_value(value)
return f"{column}: {problem}" if problem else None
if isinstance(value, (dict, list)):
return f"{column}: 글자나 숫자만 넣을 수 있음"
return None
def plan_save(
kind: str,
shown: list[dict[str, Any]],
edits: list[dict[str, Any]],
added: list[dict[str, Any]],
deleted: list[str],
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[str]]:
"""[저장] 한 번 — 다 맞춰 본 뒤 쓸 것만: (고친 줄 data · 새 줄 data · 지울 줄). 막히면 SaveError.
`shown` = `rows(kind)`(기계는 계산 칸까지 붙은 것) — 쓰는 값은 그 줄 `@data` 에 고친 칸만 얹음.
새 줄 열쇠 = `added/{난수}`(브레인 계약에 열쇠 규칙이 없어 서버가 줌 · 응답으로 알림).
"""
by_key = {r["@id"]: r for r in shown}
info = spec(kind, _columns(kind, shown))
axis = _axis_of(kind)
fillable = {c for r in shown for c in r["@data"] if not c.startswith("@")} # 계산 칸 제외
changed: dict[str, dict[str, Any]] = {}
for edit in edits:
row = by_key.get(edit["row_key"])
if row is None:
raise SaveError(404, f"없는 줄: {edit['row_key']}")
problem = _cell_problem(row, edit["column"], edit["value"], info, axis, fillable)
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:
raise SaveError(400, problem)
changed.setdefault(row["@id"], dict(row["@data"]))[edit["column"]] = edit["value"]
new: dict[str, dict[str, Any]] = {}
for data in added:
for column, value in data.items():
problem = _cell_problem({"@added": True}, column, value, info, axis, fillable)
if problem:
raise SaveError(400, problem)
new[f"added/{uuid.uuid4().hex[:12]}"] = dict(data)
missing = [key for key in deleted if key not in by_key]
if missing:
raise SaveError(404, f"없는 줄: {', '.join(missing)}")
return changed, new, list(dict.fromkeys(deleted))
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))
@@ -1,7 +1,7 @@
"""Z01 기초단가 — 기계 한 표: 기종 한 줄 + 시간당 단가(계산값) · 산출근거(2026-09-15 브레인 계약).
기계단가는 취득가 613 · 손료계수 387 · 운전경비(연료·잡재료·조종원 — 건설품셈 8-4) · 노임 · 유가가 계산으로 물림.
고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **DB 의 지금 값**을 끌어 씀
고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **덮개 얹은 값**을 끌어 씀
계산 칸 = 손료 · 연료 · 조종원 · 시간당 단가 — editable 에서 빼고 locked 에 까닭
⚠ 셈은 B09 함수를 그대로 부름(`load_machine_catalog` · `load_operating_records` · `hourly_machine_cost`) — 식을 두 벌로 안 둠.
`hourly_cost_of`(B09) 와 같은 값인지 `test_z01_base_prices.py` 가 대조. 반만 선 값(연료·조종원 빠짐)은 시간당 단가를 안 냄.
@@ -9,14 +9,13 @@
from __future__ import annotations
from dataclasses import dataclass
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,
MachineSpec,
hourly_machine_cost,
load_machine_catalog,
)
@@ -41,7 +40,7 @@ _FORMULA = {
_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜"
_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님"
_LOCKED = {
"machine_code": "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음",
"machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음",
"machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"specification": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
@@ -51,8 +50,8 @@ _LOCKED = {
"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": "노임 표에서 고침 — 여기는 건설 노임을 끌어 씀",
"fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀",
"operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀",
"hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬",
"effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜",
"operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일",
@@ -76,7 +75,7 @@ def _fmt(value: Decimal | int | float) -> str:
@dataclass(frozen=True)
class Inputs:
"""기계 셈이 다른 표에서 끌어 오는 값 — DB 지금 값 · 판 기준일과 함께."""
"""기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께."""
wages: dict[str, Decimal | None]
wage_date: str | None
@@ -124,13 +123,10 @@ def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, An
def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
"""DB 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.
기종 제원은 줄 값에서(카탈로그 아님) — 관리자가 추가한 줄도 섬 · 운전경비 원단위는 코드로 찾아 없으면 빈칸.
"""
_, records = _inputs()
"""덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길."""
catalog, records = _inputs()
code = row["@id"]
coef = row.get("loss_coefficient_per_hour")
coef = row["loss_coefficient_per_hour"]
record = records.get(code)
out = {
**row,
@@ -141,17 +137,13 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
"operator_wage_date": None,
**dict.fromkeys(COMPUTED),
}
if coef is None or row.get("price_thousand_krw") is None:
out["hourly_note"] = f"{'손료계수' if coef is None else '취득가'} 없음 — 시간당 단가 안 섬"
if coef is None:
out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬"
return out
spec = MachineSpec(
machine_code=code,
name=str(row.get("machine_name") or ""),
specification=str(row.get("specification") or ""),
spec = replace(
catalog.machines[code],
price_thousand_krw=Decimal(str(row["price_thousand_krw"])),
loss_coefficient_per_hour=Decimal(str(coef)),
economic_life_hours=row.get("economic_life_hours"),
annual_standard_hours=row.get("annual_standard_hours"),
)
liters = fuel_price = wage = None
if record is not None:
@@ -4,7 +4,7 @@
태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래
안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 ·
구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임)
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 다른 표가 줄을 못 찾음.
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음.
⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건).
"""
+202
View File
@@ -0,0 +1,202 @@
"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」).
원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움
덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일
읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}`
state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음):
edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본)
source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함)
orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김
되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌.
"""
from __future__ import annotations
import json
import math
import threading
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override"
EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan"
#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격).
#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16).
MEANING_CHANGED = "meaning_changed"
STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위
_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요
def _path(kind: str) -> Path:
return OVERRIDE_DIR / f"{kind}.json"
def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]:
"""`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개."""
path = _path(kind)
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {}
def same(a: Any, b: Any) -> bool:
"""값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음."""
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return Decimal(str(a)) == Decimal(str(b))
return a == b
def check_value(value: Any) -> str | None:
"""넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "숫자만 넣을 수 있음"
if not math.isfinite(value) or value < 0:
return "0 이상 유한한 수만 넣을 수 있음"
return None
#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌.
_JOIN = " · "
def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]:
"""(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘."""
stored = entry.get("meaning") or {}
keys = [k for k in stored if row is not None and k in row]
if not keys or all(row[k] == stored[k] for k in keys):
return "", ""
return (
_JOIN.join(str(stored[k]) for k in keys),
_JOIN.join(str(row[k]) for k in keys),
)
def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]:
original, value = entry.get("original"), entry.get("value")
was, now = meaning_line(entry, row)
if was:
state = MEANING_CHANGED
else:
state = EDITED if same(original, current) else SOURCE_CHANGED
out = {"original": original, "value": value, "state": state, "current": current}
if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감
out["was"], out["now"] = was, now
return out
def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**."""
store = load(kind)
out = []
for row in rows:
entries = store.get(row["@id"])
if not entries:
out.append(row)
continue
row = dict(row)
marks = {}
for column, entry in entries.items():
if column not in row:
continue # 주인 없는 칸 — 모아 보기에만
marks[column] = mark(entry, row[column], row)
row[column] = entry.get("value")
if marks:
row["@overrides"] = marks
out.append(row)
return out
def write(
kind: str,
base_row: dict[str, Any],
values: dict[str, Any],
by: Any,
meaning: dict[str, Any] | None = None,
) -> None:
"""고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림."""
row_id = base_row["@id"]
with _LOCK:
store = load(kind)
entries = dict(store.get(row_id) or {})
for column, value in values.items():
current = base_row.get(column)
if value is None or same(value, current):
entries.pop(column, None)
continue
entries[column] = {
"value": value,
"original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed
"source": base_row.get("@source", ""),
# 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음
"meaning": meaning or {},
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
"by": by,
}
if entries:
store[row_id] = entries
else:
store.pop(row_id, None)
_save(kind, store)
def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None:
atomic_write_json(
_path(kind),
{
"schema_version": "1.0",
"kind": kind,
"note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀",
"overrides": store,
},
)
def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int:
"""한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수."""
with _LOCK:
doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state}
if not doomed:
return 0
store = load(kind)
for row_id, column in doomed:
store[row_id].pop(column, None)
if not store[row_id]:
del store[row_id]
_save(kind, store)
return len(doomed)
def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로."""
by_id = {r["@id"]: r for r in base_rows}
out = []
for row_id, entries in load(kind).items():
row = by_id.get(row_id)
for column, entry in entries.items():
if row is None or column not in row:
current, marked = None, {"state": ORPHAN}
else:
current = row[column]
marked = mark(entry, current, row)
out.append(
{
"kind": kind,
"row_id": row_id,
"column": column,
"original": entry.get("original"),
"current": current,
"value": entry.get("value"),
"state": marked["state"],
**({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}),
}
)
return out
@@ -1,168 +0,0 @@
"""Z01 기초단가 저장소 — `master_base_price` · `master_revision` (020 · PLAN 1-0 계약 · 2026-09-18).
정본 = DB. 줄 = (kind, row_key) 한 줄 · `data` 는 지금 값 한 줄 전체 · `seed` 는 주입 때 초기값(관리자 추가 줄은 NULL).
판번호 = `master_revision.revision`(scope `base:{kind}`) — 쓸 때마다 +1 · 읽어 간 판과 다르면 안 씀(409).
⚠ 여기는 SQL 만 — 무엇을 고칠 수 있나·값 검사는 `Z01_MasterData_BasePrices` 가 함.
"""
from __future__ import annotations
import json
from typing import Any
import aiomysql
from config.config_db import get_db_pool
class RevisionConflict(Exception):
"""읽어 간 판번호가 지금 판과 다름 — 그 사이 누가 저장함."""
class UnknownRows(LookupError):
"""없는 줄 열쇠 — 되돌릴 줄을 못 찾음."""
def _scope(kind: str) -> str:
return f"base:{kind}"
def _json(text: Any) -> Any:
return json.loads(text) if isinstance(text, (str, bytes)) else text
def _dump(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
async def load(kind: str) -> list[dict[str, Any]]:
"""지운 줄 뺀 전부 — 넣은 차례(id) 그대로. {row_key, data, seed}."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT row_key, data, seed FROM master_base_price
WHERE kind = %s AND is_deleted = 0 ORDER BY id""",
(kind,),
)
return [
{"row_key": r["row_key"], "data": _json(r["data"]), "seed": _json(r["seed"])}
for r in await cursor.fetchall()
]
async def revision(kind: str) -> int:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT revision FROM master_revision WHERE scope = %s", (_scope(kind),)
)
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def counts() -> dict[str, int]:
"""kind 마다 줄 수(지운 줄 뺌) — 표 목록 한 번에."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT kind, COUNT(*) FROM master_base_price WHERE is_deleted = 0 GROUP BY kind"
)
return {kind: int(n) for kind, n in await cursor.fetchall()}
async def _lock_revision(cursor: Any, kind: str) -> int:
"""판번호 줄을 잠그고 읽음 — 없으면 0 으로 세움(같은 kind 쓰기는 여기서 한 줄로 섬)."""
await cursor.execute(
"INSERT IGNORE INTO master_revision (scope, revision) VALUES (%s, 0)", (_scope(kind),)
)
await cursor.execute(
"SELECT revision FROM master_revision WHERE scope = %s FOR UPDATE", (_scope(kind),)
)
return int((await cursor.fetchone())[0])
async def _bump(cursor: Any, kind: str) -> None:
await cursor.execute(
"UPDATE master_revision SET revision = revision + 1 WHERE scope = %s", (_scope(kind),)
)
async def save(
kind: str,
base_revision: int,
changed: dict[str, dict[str, Any]],
added: dict[str, dict[str, Any]],
deleted: list[str],
by: int | None,
) -> int:
"""한 트랜잭션 — 판이 맞을 때만 고친 줄·새 줄·지운 줄을 한꺼번에(전부 아니면 전무). 새 판번호."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
current = await _lock_revision(cursor, kind)
if current != base_revision:
raise RevisionConflict(current)
for row_key, data in changed.items():
await cursor.execute(
"""UPDATE master_base_price
SET data = %s, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND row_key = %s AND is_deleted = 0""",
(_dump(data), by, kind, row_key),
)
for row_key, data in added.items():
await cursor.execute(
"""INSERT INTO master_base_price
(kind, row_key, data, seed, edition, is_deleted, updated_by, updated_at)
VALUES (%s, %s, %s, NULL, %s, 0, %s, CURRENT_TIMESTAMP)""",
(kind, row_key, _dump(data), str(data.get("effective_date") or ""), by),
)
for row_key in deleted:
await cursor.execute(
"""UPDATE master_base_price
SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND row_key = %s""",
(by, kind, row_key),
)
await _bump(cursor, kind)
await connection.commit()
return current + 1
except Exception:
await connection.rollback()
raise
async def reset(kind: str, row_keys: list[str], by: int | None) -> int:
"""고른 줄을 초기값으로 — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄(seed NULL)은 지움. 새 판번호."""
keys = list(dict.fromkeys(row_keys))
marks = ", ".join(["%s"] * len(keys))
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
current = await _lock_revision(cursor, kind)
await cursor.execute(
f"SELECT row_key FROM master_base_price WHERE kind = %s AND row_key IN ({marks})",
(kind, *keys),
)
missing = set(keys) - {r[0] for r in await cursor.fetchall()}
if missing:
raise UnknownRows(sorted(missing))
await cursor.execute(
f"""UPDATE master_base_price
SET data = seed, is_deleted = 0, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND seed IS NOT NULL AND row_key IN ({marks})""",
(by, kind, *keys),
)
await cursor.execute(
f"""UPDATE master_base_price
SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND seed IS NULL AND row_key IN ({marks})""",
(by, kind, *keys),
)
await _bump(cursor, kind)
await connection.commit()
return current + 1
except Exception:
await connection.rollback()
raise
@@ -1,181 +0,0 @@
"""Z01 공종 저장소 — `master_work_item`·`master_work_item_row`·`master_revision` 읽기·쓰기(SQL 만).
무엇을 쓸지는 `Z01_MasterData_WorkItems` 순수 함수(`plan_rows`·`reset_plan`)가 정함 ·
여기는 받은 대로 씀.
표 모양 = `db_management/020_master_cost.sql`(PLAN 1-0 계약). 트랜잭션은 부르는 쪽(라우터)이 엶.
"""
from __future__ import annotations
import json
from typing import Any
import aiomysql
HEAD_COLUMNS = (
"id, axis, work_item_key, code, number, name, path_name, parent_key, parent_mode, "
"basis_quantity, basis_unit, status, notes, source, seed, edition, sort_order, "
"updated_by, updated_at"
)
ROW_COLUMNS = (
"id, variant, resource_kind, resource_key, resource_name, resource_spec, amount, "
"amount_unit, alternative_amount, group_ratio_pct, raw_cell, pum_table_id, seed, "
"sort_order, is_deleted"
)
_JSON = ("notes", "source", "seed")
def _parsed(row: dict[str, Any]) -> dict[str, Any]:
"""JSON 칸은 MariaDB 가 글자로 돌려줌 — 풀어서 씀."""
for key in _JSON:
if isinstance(row.get(key), (str, bytes)):
row[key] = json.loads(row[key])
return row
def _like(q: str) -> str:
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
async def list_items(
connection: aiomysql.Connection, *, axis: str, status: str, q: str, page: int, size: int
) -> dict[str, Any]:
"""목록 한 쪽 + 상태별 개수(상태 거르기 전 · 탭 숫자용). total 은 개수에서 셈(질의 하나 덜)."""
where, params = ["1"], []
if axis:
where.append("w.axis = %s")
params.append(axis)
if q:
where.append("(w.work_item_key LIKE %s OR w.number LIKE %s OR w.path_name LIKE %s)")
params += [_like(q)] * 3
picked, picked_params = list(where), list(params)
if status:
picked.append("w.status = %s")
picked_params.append(status)
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT w.status, COUNT(*) AS n FROM master_work_item w "
f"WHERE {' AND '.join(where)} GROUP BY w.status",
params,
)
counts = {row["status"]: int(row["n"]) for row in await cursor.fetchall()}
await cursor.execute(
"SELECT w.work_item_key, w.axis, w.number, w.name, w.path_name, w.status, "
"(SELECT COUNT(*) FROM master_work_item_row r "
" WHERE r.work_item_id = w.id AND r.is_deleted = 0) AS row_count "
f"FROM master_work_item w WHERE {' AND '.join(picked)} "
"ORDER BY w.axis, w.sort_order, w.id LIMIT %s OFFSET %s",
[*picked_params, size, (page - 1) * size],
)
items = list(await cursor.fetchall())
total = counts.get(status, 0) if status else sum(counts.values())
return {"items": items, "total": total, "counts": counts}
async def fetch_item(
connection: aiomysql.Connection, key: str, *, lock: bool = False
) -> dict[str, Any] | None:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"SELECT {HEAD_COLUMNS} FROM master_work_item WHERE work_item_key = %s"
+ (" FOR UPDATE" if lock else ""),
(key,),
)
row = await cursor.fetchone()
return _parsed(row) if row else None
async def fetch_rows(
connection: aiomysql.Connection, item_id: int, *, with_deleted: bool = False
) -> list[dict[str, Any]]:
"""구성 줄 — 기본은 살아 있는 줄만 · [초기값으로] 는 숨긴 줄까지."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"SELECT {ROW_COLUMNS} FROM master_work_item_row WHERE work_item_id = %s"
+ ("" if with_deleted else " AND is_deleted = 0")
+ " ORDER BY sort_order, id",
(item_id,),
)
return [_parsed(row) for row in await cursor.fetchall()]
async def revision(connection: aiomysql.Connection, scope: str, *, lock: bool = False) -> int:
"""지금 revision(없으면 0). lock 이면 줄을 만들어 잡음 — 같은 공종 저장이 줄 서게."""
async with connection.cursor() as cursor:
if lock:
await cursor.execute(
"INSERT IGNORE INTO master_revision (scope, revision) VALUES (%s, 0)", (scope,)
)
await cursor.execute(
"SELECT revision FROM master_revision WHERE scope = %s"
+ (" FOR UPDATE" if lock else ""),
(scope,),
)
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def bump_revision(connection: aiomysql.Connection, scope: str) -> None:
async with connection.cursor() as cursor:
await cursor.execute(
"INSERT INTO master_revision (scope, revision) VALUES (%s, 1) "
"ON DUPLICATE KEY UPDATE revision = revision + 1",
(scope,),
)
async def known_resource_keys(connection: aiomysql.Connection, keys: set[str]) -> set[str]:
"""그중 기초단가(`master_base_price.row_key`)에 실재하는 열쇠."""
if not keys:
return set()
marks = ", ".join(["%s"] * len(keys))
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT DISTINCT row_key FROM master_base_price "
f"WHERE is_deleted = 0 AND row_key IN ({marks})",
list(keys),
)
return {row[0] for row in await cursor.fetchall()}
async def write(
connection: aiomysql.Connection,
item_id: int,
head: dict[str, Any],
plan: dict[str, list[Any]],
by: Any,
) -> None:
"""머리 칸 + 줄 쓰기(`plan_rows`·`reset_plan` 모양).
칸 이름은 순수 함수의 고정 목록에서만 옴(요청 글자가 SQL 에 안 들어감).
"""
async with connection.cursor() as cursor:
sets = "".join(f"{field} = %s, " for field in head)
await cursor.execute(
f"UPDATE master_work_item SET {sets}updated_by = %s, updated_at = NOW() WHERE id = %s",
[*head.values(), by, item_id],
)
for row in plan["updates"]: # 줄마다 칸이 다를 수 있음(seed 에 있는 칸만) — 한 공종 수십 줄
values = {k: v for k, v in row.items() if k != "id"}
sets = "".join(f"{field} = %s, " for field in values)
await cursor.execute(
f"UPDATE master_work_item_row SET {sets}is_deleted = 0 "
"WHERE id = %s AND work_item_id = %s",
[*values.values(), row["id"], item_id],
)
if plan["inserts"]:
fields = list(plan["inserts"][0])
await cursor.executemany(
f"INSERT INTO master_work_item_row (work_item_id, {', '.join(fields)}) "
f"VALUES (%s, {', '.join(['%s'] * len(fields))})",
[[item_id, *(row[f] for f in fields)] for row in plan["inserts"]],
)
for ids, sql in (
(plan["hide"], "UPDATE master_work_item_row SET is_deleted = 1"),
(plan["drop"], "DELETE FROM master_work_item_row"),
):
if ids:
await cursor.execute(
f"{sql} WHERE work_item_id = %s AND id IN ({', '.join(['%s'] * len(ids))})",
[item_id, *ids],
)
+39 -113
View File
@@ -1,69 +1,32 @@
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약).
"""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=[&거르기=값] 한 표 + revision · 줄마다 changed_columns · is_added
PUT /api/master-data/base-prices/{kind} {base_revision, edits, added, deleted} → 409 · 전부 아니면 전무
POST /api/master-data/base-prices/{kind}/reset {row_keys} → 초기값으로(추가 줄은 지움)
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]` 로 붙임.
⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장).
"""
from __future__ import annotations
import asyncio
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
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_Repository_BasePrices as repo
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"])
_STALE = "그 사이 누가 이 표를 저장했습니다 — 표를 다시 불러와 고칠 것."
class CellEdit(BaseModel):
model_config = ConfigDict(extra="forbid")
row_key: str = Field(min_length=1, max_length=190)
column: str = Field(min_length=1, max_length=100)
value: Any = None
class AddedRow(BaseModel):
model_config = ConfigDict(extra="forbid")
data: dict[str, Any] = Field(min_length=1)
class BasePriceSave(BaseModel):
"""[저장] 한 번 — 읽어 간 판번호와 함께 고친 칸·새 줄·지운 줄."""
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=0)
edits: list[CellEdit] = Field(default_factory=list, max_length=500)
added: list[AddedRow] = Field(default_factory=list, max_length=500)
deleted: list[str] = Field(default_factory=list, max_length=500)
class BasePriceReset(BaseModel):
model_config = ConfigDict(extra="forbid")
row_keys: list[str] = Field(min_length=1, max_length=10000)
def _error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
class BasePriceEdit(BaseModel):
values: dict[str, Any]
@router.get("/tree")
@@ -87,19 +50,14 @@ def _kind(kind: str) -> str:
return kind
def _db_kind(kind: str) -> str | None:
"""고칠 수 있는 표(DB 정본) — 공종 축은 여기서 안 고침(`/work-items` 몫)."""
return kind if kind in base_prices.KINDS and kind not in work_items.KINDS else None
@router.get("/base-prices")
async def get_base_price_kinds() -> dict:
def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"status": "success", "kinds": await base_prices.kinds()}
return {"kinds": base_prices.kinds()}
@router.get("/base-prices/{kind}")
async def get_base_prices(
def get_base_prices(
kind: str,
request: Request,
page: int = 1,
@@ -108,71 +66,39 @@ async def get_base_prices(
sort: str = "",
desc: int = 0,
) -> dict:
kind = _kind(kind)
# 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실)
picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS}
if _db_kind(kind):
all_rows, revision = await asyncio.gather(base_prices.rows(kind), repo.revision(kind))
else:
all_rows, revision = await base_prices.rows(kind), 0
try:
table = await run_in_threadpool(
base_prices.table,
kind,
all_rows,
page=page,
size=size,
q=q,
sort=sort,
desc=bool(desc),
filters=picked,
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
return {"status": "success", "revision": revision, **table}
@router.put("/base-prices/{kind}")
async def put_base_prices(
kind: str, body: BasePriceSave, session: dict = Depends(verify_session)
@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:
"""고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀)."""
if not _db_kind(kind):
return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}")
if await repo.revision(kind) != body.base_revision:
return _error(409, _STALE)
try:
changed, added, deleted = base_prices.plan_save(
kind,
await base_prices.rows(kind),
[edit.model_dump() for edit in body.edits],
[row.data for row in body.added],
body.deleted,
)
except base_prices.SaveError as error:
return _error(error.status, str(error))
if not (changed or added or deleted):
return JSONResponse(
content={"status": "success", "revision": body.base_revision, "added": []}
)
try:
revision = await repo.save(
kind, body.base_revision, changed, added, deleted, session.get("user_id")
)
except repo.RevisionConflict:
return _error(409, _STALE)
return JSONResponse(content={"status": "success", "revision": revision, "added": list(added)})
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.post("/base-prices/{kind}/reset")
async def reset_base_prices(
kind: str, body: BasePriceReset, session: dict = Depends(verify_session)
) -> JSONResponse:
"""고른 줄을 초기값으로 — 주입 줄은 data := seed · 추가 줄은 지움. 판번호 +1."""
if not _db_kind(kind):
return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}")
try:
revision = await repo.reset(kind, body.row_keys, session.get("user_id"))
except repo.UnknownRows as error:
return _error(404, f"없는 줄: {', '.join(error.args[0])}")
return JSONResponse(content={"status": "success", "revision": revision})
@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)}
@@ -1,152 +0,0 @@
"""Z01 공종 편집 라우터 — 공종 머리·구성 줄을 관리자가 고치고 메움(2026-09-18 PLAN 1-0 계약).
GET /api/master-data/work-items?axis&status&q&page&size 목록 + 상태별 개수
GET /api/master-data/work-items/{key} 머리(+revision)·구성 줄·원문 표·[주]
PUT /api/master-data/work-items/{key} 전체 줄 보내기
→ 409(낡은 revision) · 422(검사)
POST /api/master-data/work-items/{key}/reset 머리·줄을 초기값으로(추가 줄 삭제)
쓰기는 한 트랜잭션(전부 아니면 전무) · 응답은 새 GET 모양 그대로(화면이 다시 안 불러도 됨).
⚠ 권한은 등록하는 쪽(`main.py`)이 시스템 관리자 전용 묶음으로 붙임.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from Z01_MasterData import Z01_MasterData_Repository_WorkItems as repo
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 WorkItems"])
Status = Literal[work_items.STATUSES] # 튜플을 펼침 — 상태 이름은 한 곳(`STATUSES`)
MAX_ROWS = 500
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid")
class ItemHead(_Strict):
name: str = Field(min_length=1)
basis_quantity: Decimal | None = Field(default=None, ge=0)
basis_unit: str | None = None
status: Status
class ItemRow(_Strict):
id: int | None = None # 있으면 수정 · 없으면 추가
variant: str = ""
resource_kind: Literal["labor", "machine", "material", "etc"]
resource_key: str | None = Field(default=None, min_length=1) # 못 맞춘 줄은 null(빈 글자 금지)
resource_name: str = ""
resource_spec: str = ""
amount: Decimal | None = Field(default=None, ge=0)
amount_unit: str = ""
alternative_amount: Decimal | None = Field(default=None, ge=0)
group_ratio_pct: Decimal | None = Field(default=None, ge=0)
raw_cell: str | None = None
pum_table_id: str = ""
class WorkItemSave(_Strict):
base_revision: int
item: ItemHead
rows: list[ItemRow] = Field(max_length=MAX_ROWS) # 전체 줄 — 빠진 줄은 삭제
@router.get("/work-items")
async def list_work_items(
axis: Literal["", "forest", "const"] = "",
status: Status | Literal[""] = "",
q: str = "",
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
) -> dict[str, Any]:
page, size = max(page, 1), max(1, min(size, tables.MAX_PAGE_SIZE))
async with get_db_pool().acquire() as connection:
found = await repo.list_items(
connection, axis=axis, status=status, q=q.strip(), page=page, size=size
)
counts = {s: found["counts"].get(s, 0) for s in work_items.STATUSES}
return {"status": "success", **found, "counts": counts, "page": page, "size": size}
async def _detail(connection: Any, key: str) -> dict[str, Any]:
head = await repo.fetch_item(connection, key)
if head is None:
raise HTTPException(status_code=404, detail=f"없는 공종: {key}")
rows = await repo.fetch_rows(connection, head["id"])
revision = await repo.revision(connection, work_items.revision_scope(key))
return {"status": "success", **work_items.detail(head, rows, revision)}
@router.get("/work-items/{key}")
async def get_work_item(key: str) -> dict[str, Any]:
async with get_db_pool().acquire() as connection:
return await _detail(connection, key)
async def _locked(connection: Any, key: str) -> tuple[dict[str, Any], int]:
"""공종 머리 + revision 을 잡음(같은 공종 저장이 줄 섬)."""
head = await repo.fetch_item(connection, key, lock=True)
if head is None:
raise HTTPException(status_code=404, detail=f"없는 공종: {key}")
return head, await repo.revision(connection, work_items.revision_scope(key), lock=True)
@router.put("/work-items/{key}")
async def put_work_item(
key: str, body: WorkItemSave, session: dict = Depends(verify_session)
) -> dict[str, Any]:
rows = [row.model_dump() for row in body.rows]
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
head, current = await _locked(connection, key)
if current != body.base_revision:
raise HTTPException(
status_code=409,
detail="다른 곳에서 먼저 고쳤음 — 다시 불러올 것"
f"(지금 {current} · 보낸 {body.base_revision})",
)
existing = await repo.fetch_rows(connection, head["id"])
known = await repo.known_resource_keys(
connection, {row["resource_key"] for row in rows if row["resource_key"]}
)
errors = work_items.check_rows(rows, {row["id"] for row in existing}, known)
if errors:
raise HTTPException(status_code=422, detail="\n".join(errors))
plan = work_items.plan_rows(existing, rows)
await repo.write(
connection, head["id"], body.item.model_dump(), plan, session.get("user_id")
)
await repo.bump_revision(connection, work_items.revision_scope(key))
await connection.commit()
except BaseException:
await connection.rollback()
raise
return await _detail(connection, key)
@router.post("/work-items/{key}/reset")
async def reset_work_item(key: str, session: dict = Depends(verify_session)) -> dict[str, Any]:
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
head, _current = await _locked(connection, key)
rows = await repo.fetch_rows(connection, head["id"], with_deleted=True)
plan = work_items.reset_plan(head, rows)
await repo.write(connection, head["id"], plan["head"], plan, session.get("user_id"))
await repo.bump_revision(connection, work_items.revision_scope(key))
await connection.commit()
except BaseException:
await connection.rollback()
raise
return await _detail(connection, key)
-263
View File
@@ -1,263 +0,0 @@
/* =============================================================================
* Z01_MasterData_UI_Draft.ts
* [] (sessionStorage) · (revision)
*
* `B08_Quantity_UI_StructureSummary.ts` Draft · `z01-draft:{scope}`
* (scope = `base:{kind}` · `work_item:{key}`) PLAN 1-0 · (CLAUDE.md 5).
* import (`test_z01_master_draft.py`) .
* ========================================================================== */
import type {
BaseSave,
WorkItemDetail,
WorkItemHead,
WorkItemRow,
WorkItemSave,
} from "./Z01_MasterData_Api_Fetch";
const PREFIX = "z01-draft:";
/** 저장소의 초안 — 판이 다르거나 못 읽으면 null(낡은 판 초안은 지움). */
export function readDraft<T>(scope: string, revision: number): T | null {
try {
const raw = sessionStorage.getItem(PREFIX + scope);
const saved = JSON.parse(raw ?? "null") as { revision: number; body: T } | null;
if (saved?.revision === revision) return saved.body;
if (saved) sessionStorage.removeItem(PREFIX + scope);
} catch {
// 캐시를 못 읽으면 빈 초안 — 정본은 그대로.
}
return null;
}
/** body null = 지움. */
export function writeDraft(scope: string, revision: number, body: unknown): void {
try {
if (body === null) sessionStorage.removeItem(PREFIX + scope);
else sessionStorage.setItem(PREFIX + scope, JSON.stringify({ revision, body }));
} catch {
// 저장소가 막혀도 화면 초안은 살아 있음.
}
}
export function hasDraft(scope: string): boolean {
try {
return sessionStorage.getItem(PREFIX + scope) !== null;
} catch {
return false;
}
}
/** 값이 같은가 — null·undefined 는 같게 봄. */
export function same(a: unknown, b: unknown): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
/* ── 기초단가 — 칸 고침 · 줄 추가 · 줄 삭제 ── */
type Edit = BaseSave["edits"][number];
export interface AddedRow {
/** 화면 안 임시 이름 — 서버에 안 감 */
tmp: string;
data: Record<string, unknown>;
}
interface BaseBody {
edits: Edit[];
added: AddedRow[];
deleted: string[];
}
export class BaseDraft {
readonly edits = new Map<string, Edit>();
added: AddedRow[] = [];
readonly deleted = new Set<string>();
onChange: () => void = () => undefined;
constructor(
readonly scope: string,
readonly revision: number,
) {
const saved = readDraft<BaseBody>(scope, revision);
for (const edit of saved?.edits ?? []) this.edits.set(`${edit.row_key}|${edit.column}`, edit);
this.added = saved?.added ?? [];
for (const key of saved?.deleted ?? []) this.deleted.add(key);
}
get count(): number {
return this.edits.size + this.added.length + this.deleted.size;
}
edit(rowKey: string, column: string): Edit | undefined {
return this.edits.get(`${rowKey}|${column}`);
}
/** 서버 값과 같아지면 고침에서 뺌. */
setEdit(rowKey: string, column: string, value: unknown, stored: unknown): void {
const slot = `${rowKey}|${column}`;
if (same(value, stored)) this.edits.delete(slot);
else this.edits.set(slot, { row_key: rowKey, column, value });
this.persist();
}
addRow(): AddedRow {
const row = { tmp: `new-${Date.now()}-${this.added.length}`, data: {} };
this.added.push(row);
this.persist();
return row;
}
/** 빈 값(null)이면 그 칸을 뺌 — 서버가 기본값을 씀. */
setAdded(tmp: string, column: string, value: unknown): void {
const row = this.added.find((each) => each.tmp === tmp);
if (!row) return;
if (value === null || value === "") delete row.data[column];
else row.data[column] = value;
this.persist();
}
removeAdded(tmp: string): void {
this.added = this.added.filter((each) => each.tmp !== tmp);
this.persist();
}
toggleDelete(rowKey: string): void {
if (!this.deleted.delete(rowKey)) this.deleted.add(rowKey);
this.persist();
}
clear(): void {
this.edits.clear();
this.added = [];
this.deleted.clear();
this.persist();
}
/** 보낼 몸 — 지울 줄의 칸 고침은 안 보냄. */
payload(): BaseSave {
return {
base_revision: this.revision,
edits: [...this.edits.values()].filter((edit) => !this.deleted.has(edit.row_key)),
added: this.added.map((row) => ({ data: row.data })),
deleted: [...this.deleted],
};
}
private persist(): void {
const body: BaseBody = {
edits: [...this.edits.values()],
added: this.added,
deleted: [...this.deleted],
};
writeDraft(this.scope, this.revision, this.count ? body : null);
this.onChange();
}
}
/* ── 공종 — 머리 + 전체 줄(PUT 이 전체 줄을 받음) ── */
/** 화면 줄 — tmp = 저장 전 새 줄 · deleted = [저장] 때 뺄 줄(줄은 그대로 보이며 줄 그음) */
export type DraftRow = WorkItemRow & { tmp?: string; deleted?: boolean };
export interface WorkItemBody {
item: WorkItemHead;
rows: DraftRow[];
}
export const HEAD_FIELDS = ["name", "basis_quantity", "basis_unit", "status"] as const;
/**
* PUT (is_added·changed_columns·sort_order)
* 422(extra=forbid). sort_order.
*/
export const ROW_FIELDS = [
"variant",
"resource_kind",
"resource_key",
"resource_name",
"resource_spec",
"amount",
"amount_unit",
"alternative_amount",
"group_ratio_pct",
"raw_cell",
"pum_table_id",
] as const;
/** 글자 칸 — 서버가 null 을 안 받음(빈 글자로) */
const TEXT_FIELDS = new Set([
"variant",
"resource_name",
"resource_spec",
"amount_unit",
"pum_table_id",
]);
/** 서버 값 그대로의 초안(고친 것 없음). */
export function freshBody(detail: WorkItemDetail): WorkItemBody {
const item = Object.fromEntries(
HEAD_FIELDS.map((field) => [field, detail.item[field]]),
) as unknown as WorkItemHead;
return { item, rows: detail.rows.map((row) => ({ ...row })) };
}
/** 보낼 줄 — id + 계약 칸만 · 글자 칸 null 은 빈 글자 · 빈 열쇠는 null(못 맞춘 줄). */
function rowPayload(row: DraftRow): WorkItemRow {
const out: Record<string, unknown> = row.id === undefined ? {} : { id: row.id };
for (const field of ROW_FIELDS) {
const value = row[field];
out[field] = TEXT_FIELDS.has(field) ? (value ?? "") : value === "" ? null : (value ?? null);
}
return out as unknown as WorkItemRow;
}
/** 보낼 몸 — 지울 줄은 빼고(빠지면 삭제) · 화면 표시 칸(tmp·deleted)과 GET 전용 칸은 뗌. */
export function workItemPayload(revision: number, body: WorkItemBody): WorkItemSave {
return {
base_revision: revision,
item: { ...body.item },
rows: body.rows.filter((row) => !row.deleted).map(rowPayload),
};
}
export function isDirty(detail: WorkItemDetail, body: WorkItemBody): boolean {
const base = detail.item.revision;
return !same(workItemPayload(base, body), workItemPayload(base, freshBody(detail)));
}
/** 서버 값과 다른 칸 — 새 줄은 「added」(줄째 새것). */
export function changedFields(row: DraftRow, saved: WorkItemRow[]): Set<string> | "added" {
const original = row.id === undefined ? undefined : saved.find((each) => each.id === row.id);
if (!original) return "added";
return new Set(ROW_FIELDS.filter((key) => !same(row[key], original[key])));
}
/** 갈래별로 묶음 — 처음 나온 차례 그대로 · 줄 번호(rows 안 자리)만 담음. */
export function groupByVariant(rows: DraftRow[]): { variant: string; indexes: number[] }[] {
const groups: { variant: string; indexes: number[] }[] = [];
rows.forEach((row, index) => {
const group = groups.find((each) => each.variant === row.variant);
if (group) group.indexes.push(index);
else groups.push({ variant: row.variant, indexes: [index] });
});
return groups;
}
/** 새 줄 — 그 갈래 마지막 줄 바로 뒤에 넣음(보낸 차례가 sort_order 라 갈래가 흩어지지 않게). */
export function addRow(body: WorkItemBody, variant: string, tmp: string): DraftRow {
const row: DraftRow = {
tmp,
variant,
resource_kind: "material",
resource_key: null,
resource_name: "",
resource_spec: "",
amount: null,
amount_unit: "",
alternative_amount: null,
group_ratio_pct: null,
raw_cell: null,
pum_table_id: "",
};
const last = body.rows.map((each) => each.variant).lastIndexOf(variant);
body.rows.splice(last === -1 ? body.rows.length : last + 1, 0, row);
return row;
}
+4 -25
View File
@@ -15,9 +15,8 @@ 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, type RowsView } from "./Z01_MasterData_UI_Rows";
import { buildRowsView } from "./Z01_MasterData_UI_Rows";
import { buildSidePanel } from "./Z01_MasterData_UI_Side";
import { buildWorkItemView } from "./Z01_MasterData_UI_WorkItem";
import "./Z01_MasterData_UI_Style.css";
export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
@@ -42,32 +41,12 @@ export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
}
function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement {
// 오른쪽은 표 보기 · 공종 편집 둘 중 하나 — 왼쪽에서 누른 쪽만 보임.
const rows = buildRowsView();
const items = buildWorkItemView();
items.root.hidden = true;
const swap = (showRows: boolean): void => {
rows.root.hidden = !showRows;
items.root.hidden = showRows;
};
const view: RowsView = {
root: rows.root,
show: (source) => {
swap(true);
rows.show(source);
},
};
const view = buildRowsView();
const layout = el("div", { className: "ui-workflow-layout z01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [rows.root, items.root],
});
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, (axis) => {
swap(false);
items.show(axis);
}),
optionsContent: buildSidePanel(groups, kinds, view),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
+152 -285
View File
@@ -1,11 +1,10 @@
/* =============================================================================
* Z01_MasterData_UI_Rows.ts
* ( · ) · ,
* ( · ) · ,
*
* , `edit` .
* · ·
* , `save` editable .
* · · ·
* (2026-09-15 ).
* · · (`BaseDraft`) [] (PLAN 1-0).
* ========================================================================== */
import {
@@ -13,22 +12,14 @@ import {
createInputField,
createSelectField,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
ApiError,
type BaseSave,
type MasterRows,
type RowsQuery,
} from "./Z01_MasterData_Api_Fetch";
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
import {
boldSegments,
cellInfo,
cellText,
clampPage,
columnTitle,
filterValue,
@@ -41,29 +32,24 @@ import {
rowCells,
shownColumns,
valueWithUnit,
type CellInfo,
type MasterColumn,
type SortOrder,
} from "./Z01_MasterData_UI_Cells";
import { BaseDraft, type AddedRow } from "./Z01_MasterData_UI_Draft";
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
type Row = Record<string, unknown>;
type LocaleKey = Parameters<typeof L>[0];
export interface RowsSource {
title: string;
key: string;
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */
searchable?: boolean;
load: (query: RowsQuery) => Promise<MasterRows>;
/** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */
edit?: {
scope: string;
save: (body: BaseSave) => Promise<unknown>;
reset: (rowKeys: string[]) => Promise<unknown>;
};
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
save?: (rowId: string, values: Row) => Promise<Row>;
}
export interface RowsView {
@@ -72,15 +58,36 @@ export interface RowsView {
}
/** 사전 문구의 {value} 자리에 값을 넣음. */
function fill(key: LocaleKey, value: string): string {
function fill(key: Parameters<typeof L>[0], value: string): string {
return L(key).replace("{value}", value);
}
/** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */
interface Picked {
row: string;
added: boolean;
column: string;
/**
* · ( ·· ).
* . .
*/
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 {
@@ -93,10 +100,7 @@ export function buildRowsView(): RowsView {
};
let showHidden = false;
let last: MasterRows | null = null;
let picked: Picked | null = null;
let draft: BaseDraft | null = null;
/** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */
let stale = false;
let selected: { index: number; key: string } | null = null;
let searchTimer = 0;
const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query));
@@ -149,26 +153,12 @@ export function buildRowsView(): RowsView {
className: "z01-master__pager",
children: [first, prev, pageInput, pageInfo, next, end],
});
// 기초단가 고치기 — [저장]·[고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로]
const action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") =>
createButton({ label: L(key), variant, onClick });
const saveButton = action("Z01_MasterData_Save", () => void save(), "filled");
const discardButton = action("Z01_MasterData_Discard", discard);
const addButton = action("Z01_MasterData_AddRow", addRow);
const deleteButton = action("Z01_MasterData_DeleteRow", toggleDelete);
const resetButton = action("Z01_MasterData_Reset", () => void reset());
const draftStatus = el("span", { className: "z01-master__draft-status" });
const actions = el("div", {
className: "z01-master__toolbar",
children: [saveButton, discardButton, addButton, deleteButton, resetButton, draftStatus],
});
const root = el("section", {
className: "z01-master__panel",
children: [
el("div", { children: [title, titleKey] }),
notice,
banner,
actions,
toolbar,
detail,
grid,
@@ -180,13 +170,6 @@ export function buildRowsView(): RowsView {
detail.hidden = true;
notice.hidden = true;
banner.hidden = true;
actions.hidden = true;
// 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
window.addEventListener("beforeunload", (event) => {
if (!draft?.count || !root.isConnected) return;
event.preventDefault();
event.returnValue = L("Z01_MasterData_Unsaved");
});
function goTo(page: number): void {
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
@@ -221,65 +204,15 @@ export function buildRowsView(): RowsView {
if (!data) return;
grid.classList.remove("is-loading");
last = data;
const edit = source.edit;
if (edit && data.revision !== undefined) {
// 고친 것이 남은 초안은 쪽을 넘겨도 지킴 — 그새 판이 바뀌었으면 stale 로 알림(조용히 안 버림).
if (draft?.scope !== edit.scope || !draft.count) {
draft = new BaseDraft(edit.scope, data.revision);
draft.onChange = drawActions;
}
stale = draft.revision !== data.revision;
}
picked = null;
selected = null;
detail.hidden = true;
draw();
}
const rowKey = (row: Row, index: number): string =>
row["@id"] == null ? `#${index}` : String(row["@id"]);
const changedColumns = (row: Row): string[] =>
Array.isArray(row.changed_columns) ? (row.changed_columns as string[]) : [];
/** 고른 서버 줄 — 저장 전 새 줄·열쇠 없는 줄이면 없음. */
function pickedRow(): Row | undefined {
if (!picked || picked.added) return undefined;
const key = picked.row;
return last?.rows.find((row) => row["@id"] != null && String(row["@id"]) === key);
}
/** 새 줄에 적은 글자를 수로 읽을지 — 같은 열 다른 줄 값의 꼴을 따름(코드 칸이 수로 바뀌지 않게). */
const sample = (column: string): unknown =>
last?.rows.find((row) => row[column] != null)?.[column];
function drawActions(): void {
// 서버가 판(revision)을 준 표만 고침 — 안 주는 표(공종 축 등 DB 밖)는 단추를 안 세움.
actions.hidden = !draft;
const count = draft?.count ?? 0;
const row = pickedRow();
saveButton.disabled = count === 0 || stale;
discardButton.disabled = count === 0 && !stale;
addButton.disabled = !draft || stale;
deleteButton.disabled = !draft || !(picked?.added || row);
const undo = Boolean(row && draft?.deleted.has(String(row["@id"])));
deleteButton.querySelector(".ui-btn__label")!.textContent = L(
undo ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow",
);
// 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 판이 갈려 초안이 버려지니 먼저 저장·버리게 함.
resetButton.disabled = !row || count > 0 || !(changedColumns(row).length || row.is_added);
resetButton.title = count > 0 ? L("Z01_MasterData_ResetBlocked") : "";
draftStatus.classList.toggle("is-warning", stale);
draftStatus.textContent = stale
? L("Z01_MasterData_Draft_Stale")
: count && draft
? `${L("Z01_MasterData_Draft_Edits")} ${draft.edits.size} · ${L("Z01_MasterData_Draft_Added")} ${draft.added.length} · ${L("Z01_MasterData_Draft_Deleted")} ${draft.deleted.size}${L("Z01_MasterData_Draft_Pending")}`
: L("Z01_MasterData_Draft_None");
}
function headCell(meta: MasterRows, column: MasterColumn): HTMLElement {
const th = el("th", { children: [el("span", { text: columnTitle(column) })] });
const marks: [string, string][] = [];
if (source?.edit && meta.editable?.includes(column.key)) {
if (source?.save && meta.editable?.includes(column.key)) {
marks.push(["✎", L("Z01_MasterData_Edit_Can")]);
}
const reason = meta.locked?.[column.key];
@@ -353,11 +286,39 @@ export function buildRowsView(): RowsView {
const columns = shownColumns(meta.columns, showHidden);
const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) });
const body = el("tbody");
// 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐.
for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added));
meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index)));
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(
body.children.length
meta.rows.length
? el("table", {
className: "z01-master__grid",
children: [el("thead", { children: [headRow] }), body],
@@ -370,100 +331,25 @@ export function buildRowsView(): RowsView {
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;
drawActions();
}
const isPicked = (row: string, added: boolean, column: string): boolean =>
picked?.row === row && picked.added === added && picked.column === column;
function serverRow(meta: MasterRows, columns: MasterColumn[], row: Row, index: number) {
const key = rowKey(row, index);
const texts = rowCells(row, columns);
const changed = changedColumns(row);
const deleted = draft?.deleted.has(key) ?? false;
const tr = el("tr");
if (row.is_added) tr.classList.add("is-added");
if (deleted) tr.classList.add("is-deleted");
columns.forEach((column, i) => {
const info = cellInfo(meta, row, column.key);
const stored = row[column.key];
const pending = draft?.edit(key, column.key);
const text = pending ? cellText(pending.value, column.unit) : texts[i];
const td = el("td", { text, attrs: { title: text } });
// 고친 칸 표시 — 저장 전(초안) · 저장했고 초기값과 다름(서버 changed_columns)
if (pending) td.classList.add("is-draft");
else if (changed.includes(column.key)) td.classList.add("is-changed");
const canEdit = Boolean(draft) && info.editable && row["@id"] != null && !deleted;
if (canEdit) td.classList.add("is-editable");
if (isPicked(key, false, column.key)) td.classList.add("is-selected");
td.addEventListener("click", () => {
if (td.querySelector("input")) return;
pick(td, { row: key, added: false, column: column.key });
if (!canEdit) return;
startEdit(td, pending ? pending.value : stored, stored ?? sample(column.key), (value) =>
draft?.setEdit(key, column.key, value, stored),
);
});
tr.append(td);
});
return tr;
}
function addedRow(meta: MasterRows, columns: MasterColumn[], added: AddedRow) {
const tr = el("tr", { className: "is-new", attrs: { title: L("Z01_MasterData_Row_New") } });
for (const column of columns) {
const value = added.data[column.key];
const td = el("td", { text: cellText(value, column.unit) });
// 새 줄은 식으로 나오는 칸만 빼고 다 채움 — 열쇠·이름도 사람이 넣어야 줄이 섬.
const canEdit = !meta.formula?.[column.key];
if (canEdit) td.classList.add("is-editable");
if (isPicked(added.tmp, true, column.key)) td.classList.add("is-selected");
td.addEventListener("click", () => {
if (td.querySelector("input")) return;
pick(td, { row: added.tmp, added: true, column: column.key });
if (!canEdit) return;
startEdit(
td,
value,
sample(column.key),
(next) => draft?.setAdded(added.tmp, column.key, next),
true,
);
});
tr.append(td);
}
return tr;
}
function pick(td: HTMLElement, next: Picked): void {
grid.querySelector("td.is-selected")?.classList.remove("is-selected");
td.classList.add("is-selected");
picked = next;
showDetail();
drawActions();
}
/**
* · · · · .
* `failure` = ( ).
* · · () · .
* `failure` = ( ).
*/
function showDetail(failure?: string): void {
const at = picked;
const column = last?.columns.find((each) => each.key === at?.column);
if (!last || !at || !column) return;
const row = at.added
? (draft?.added.find((each) => each.tmp === at.row)?.data ?? {})
: (pickedRow() ?? last.rows[Number(at.row.slice(1))] ?? {});
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, warn = false) => {
const span = el("span", { className: "z01-master__detail-line", text });
span.classList.toggle("is-warning", warn);
return span;
};
if (failure) lines.push(line(fill("Z01_MasterData_Edit_NotSaved", failure), true));
if (at.added) lines.push(line(L("Z01_MasterData_Row_New")));
else if (source?.edit) {
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
@@ -471,14 +357,30 @@ export function buildRowsView(): RowsView {
: `${L("Z01_MasterData_Edit_Locked")}${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`,
),
);
const pending = draft?.edit(at.row, column.key);
if (pending) {
const value = valueWithUnit(pending.value, column.unit);
lines.push(line(fill("Z01_MasterData_Cell_Draft", value)));
}
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 (changedColumns(row).includes(column.key))
lines.push(line(L("Z01_MasterData_Cell_Changed")));
if (row.is_added) lines.push(line(L("Z01_MasterData_Row_Added")));
}
if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`));
if (info.filled) lines.push(line(info.filled));
@@ -486,38 +388,31 @@ export function buildRowsView(): RowsView {
detail.hidden = false;
}
/**
* Enter· = ( ) · Esc = .
* `typeOf` = · `allowEmpty` = ( ).
*/
function startEdit(
td: HTMLElement,
current: unknown,
typeOf: unknown,
apply: (value: unknown) => void,
allowEmpty = false,
): void {
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 = (keep: boolean): void => {
const finish = (save: boolean): void => {
if (finished) return;
finished = true;
const empty = allowEmpty && input.value.trim() === "";
const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf);
const value = save ? parseCellInput(input.value, current) : current;
if (value === undefined) {
const why = fill("Z01_MasterData_Edit_Invalid", input.value);
showToast(why, "error");
draw();
showDetail(why);
selectCell(index, column, why);
return;
}
if (keep) apply(value);
draw();
showDetail();
if (value === current) {
draw();
return;
}
void commit(index, column, value);
};
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") finish(true);
@@ -526,87 +421,59 @@ export function buildRowsView(): RowsView {
input.addEventListener("blur", () => finish(true));
}
function addRow(): void {
draft?.addRow();
draw();
}
function toggleDelete(): void {
if (!draft || !picked) return;
if (picked.added) {
draft.removeAdded(picked.row);
picked = null;
detail.hidden = true;
} else if (pickedRow()) {
draft.toggleDelete(picked.row);
/** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */
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;
}
draw();
await commit(index, column, null);
}
function discard(): void {
draft?.clear();
stale = false;
void load();
}
/** [저장] — 초안 한 벌을 보냄. 409 면 초안을 지키고 알림(다른 곳에서 먼저 저장함). */
async function save(): Promise<void> {
const edit = source?.edit;
const active = draft;
if (!edit || !active?.count) return;
showLoadingOverlay();
/** 저장 — 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 {
await edit.save(active.payload());
active.clear();
showToast(L("Z01_MasterData_Saved"), "success");
void load();
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) {
if (error instanceof ApiError && error.status === 409) stale = true;
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
drawActions();
} finally {
hideLoadingOverlay();
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);
}
}
/** [초기값으로] — 고른 줄을 seed 로(추가한 줄은 지워짐) · 바로 저장이라 한 번 물음. */
async function reset(): Promise<void> {
const edit = source?.edit;
const row = pickedRow();
if (!edit || !row || draft?.count) return;
const ok = await showConfirmDialog(L("Z01_MasterData_ResetConfirm"), L("Z01_MasterData_Reset"));
if (!ok) return;
showLoadingOverlay();
try {
await edit.reset([String(row["@id"])]);
showToast(L("Z01_MasterData_ResetDone"), "success");
void load();
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
function show(next: RowsSource): void {
source = next;
function show(picked: RowsSource): void {
source = picked;
state.page = 1;
state.sort = null;
state.filters = {};
filterBox.replaceChildren();
title.textContent = next.title;
titleKey.textContent = next.key;
title.textContent = picked.title;
titleKey.textContent = picked.key;
notice.hidden = true;
banner.hidden = true;
search.root.hidden = next.searchable === false;
search.root.hidden = picked.searchable === false;
toolbar.hidden = false;
pager.hidden = false;
detail.hidden = true;
last = null;
picked = null;
draft = null;
stale = false;
actions.hidden = true;
selected = null;
grid.replaceChildren();
void load();
}
+68 -27
View File
@@ -12,22 +12,25 @@ import { t as L } from "@ui/ui_template_locale";
import {
fetchBasePrices,
fetchMasterRows,
resetBasePrices,
saveBasePrices,
fetchOverrides,
type MasterGroup,
type MasterRows,
type RowsQuery,
saveBasePrice,
VALUES_TABLE_ID,
type WorkItemAxis,
} from "./Z01_MasterData_Api_Fetch";
import { groupKinds, type ServedKind } from "./Z01_MasterData_UI_Cells";
import {
groupKinds,
valueWithUnit,
type OverrideState,
type ServedKind,
} from "./Z01_MasterData_UI_Cells";
import type { RowsView } from "./Z01_MasterData_UI_Rows";
const AXES: WorkItemAxis[] = ["forest", "const"];
export function buildSidePanel(
groups: MasterGroup[],
kinds: ServedKind[],
view: RowsView,
openWorkItems: (axis: WorkItemAxis) => void,
): HTMLElement {
const panel = el("div", { className: "z01-master__side" });
const activate = (button: HTMLElement): void => {
@@ -56,32 +59,29 @@ export function buildSidePanel(
title: `${box.label} ${served.label}`,
key: `base-prices/${served.kind}`,
load: (query) => fetchBasePrices(served.kind, query),
edit: {
scope: `base:${served.kind}`,
save: (body) => saveBasePrices(served.kind, body),
reset: (rowKeys) => resetBasePrices(served.kind, rowKeys),
},
save: (rowId, values) => saveBasePrice(served.kind, rowId, values),
});
});
grid.append(button);
}
return { label: box.label, grid };
});
// 공종 편집 — 구성 줄을 고치고 「입력 필요」 공종을 메움(PLAN 1-4).
const workGrid = el("div", { className: "z01-master__base-grid" });
for (const axis of AXES) {
const button = el("button", {
className: "z01-master__base",
attrs: { type: "button" },
text: L(axis === "forest" ? "Z01_WorkItem_Axis_Forest" : "Z01_WorkItem_Axis_Const"),
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]))),
});
button.addEventListener("click", () => {
activate(button);
openWorkItems(axis);
});
workGrid.append(button);
}
});
boxes[0]?.grid.append(overridesButton);
const tree = el("nav", { className: "z01-master__tree" });
for (const group of groups) {
@@ -132,13 +132,54 @@ export function buildSidePanel(
panel.append(
...boxes.map((box) => section(box.label, box.grid)),
section(L("Z01_WorkItem_Title"), workGrid),
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",
-201
View File
@@ -315,204 +315,3 @@
width: 6em;
text-align: right;
}
/* ── 고치기 — 저장 전 초안 · 저장된 고침(초기값과 다름) · 새 줄 · 지울 줄 (PLAN 1-4) ── */
.z01-master__grid td.is-draft,
.z01-wi__field.is-draft {
background: color-mix(in srgb, var(--color-warning) 24%, var(--color-canvas));
font-weight: var(--font-weight-bold);
}
.z01-master__grid td.is-changed,
.z01-wi__field.is-changed {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.z01-master__grid tr.is-new td {
background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas));
}
.z01-master__grid tr.is-added td:first-child {
box-shadow: inset 4px 0 0 var(--color-success);
}
.z01-master__grid tr.is-deleted td {
color: var(--color-text-muted);
text-decoration: line-through;
}
.z01-master__draft-status {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.z01-master__draft-status.is-warning {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
/* ── 공종 편집 — 왼쪽 목록 / 오른쪽 머리·구성 줄·원문 ── */
.z01-master__panel.z01-wi {
display: grid;
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
align-items: start;
gap: var(--spacing-16);
}
.z01-wi__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
position: sticky;
top: 0;
}
.z01-wi__list {
display: flex;
flex-direction: column;
max-height: calc(100vh - 260px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.z01-wi__list.is-loading {
opacity: 0.5;
}
.z01-wi__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--spacing-4) var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
text-align: left;
cursor: pointer;
}
.z01-wi__item:hover {
background: var(--color-paper);
}
.z01-wi__item.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.z01-wi__item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.z01-wi__status {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.z01-wi__status.is-needs-input,
.z01-wi__status.is-partial,
.z01-wi__unmatched {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.z01-wi__status.is-reviewed {
color: var(--color-success);
}
.z01-wi__editor {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.z01-wi__head {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-8) var(--spacing-16);
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-wi__head:empty {
display: none;
}
.z01-wi__field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px var(--spacing-4);
border-radius: var(--radius-sm);
}
.z01-wi__label {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.z01-wi__heading {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.z01-wi__variant {
margin: var(--spacing-8) 0 var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.z01-wi__grid td {
padding: 2px var(--spacing-4);
}
.z01-wi__grid input,
.z01-wi__grid select {
min-width: 4em;
padding: 2px var(--spacing-4);
font-size: 12px;
}
.z01-wi__grid select {
min-width: 6em;
}
.z01-wi__grid input[type="number"] {
width: 6em;
}
.z01-wi__finder {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border: 2px solid var(--color-accent);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.z01-wi__found {
display: flex;
flex-direction: column;
max-height: 240px;
overflow: auto;
}
.z01-wi__notes {
margin: 0;
padding-left: 1.2em;
font-size: var(--text-body-sm);
}
@@ -1,409 +0,0 @@
/* =============================================================================
* Z01_MasterData_UI_WorkItem.ts
* (·) / / / +[] (PLAN 1-4)
*
* ( · ) .
* (sessionStorage `z01-draft:work_item:{key}`)
* [] + · (revision) 409 .
* []·[ ] (CLAUDE.md 5).
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
ApiError,
fetchWorkItem,
fetchWorkItems,
resetWorkItem,
saveWorkItem,
type WorkItemAxis,
type WorkItemDetail,
type WorkItemHead,
type WorkItemList,
type WorkItemListRow,
type WorkItemStatus,
} from "./Z01_MasterData_Api_Fetch";
import { latestOnly, pageCount } from "./Z01_MasterData_UI_Cells";
import {
freshBody,
hasDraft,
isDirty,
readDraft,
same,
workItemPayload,
writeDraft,
type WorkItemBody,
} from "./Z01_MasterData_UI_Draft";
import { renderSource, renderWorkRows } from "./Z01_MasterData_UI_WorkItem_Rows";
type LocaleKey = Parameters<typeof L>[0];
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
/** 목록 차례 — 메울 것부터 */
const STATUSES: WorkItemStatus[] = ["needs_input", "partial", "auto_ok", "not_item", "reviewed"];
const STATUS_LABEL: Record<WorkItemStatus, LocaleKey> = {
needs_input: "Z01_WorkItem_Status_NeedsInput",
partial: "Z01_WorkItem_Status_Partial",
auto_ok: "Z01_WorkItem_Status_AutoOk",
not_item: "Z01_WorkItem_Status_NotItem",
reviewed: "Z01_WorkItem_Status_Reviewed",
};
const scopeOf = (key: string): string => `work_item:${key}`;
export interface WorkItemView {
root: HTMLElement;
show: (axis: WorkItemAxis) => void;
}
export function buildWorkItemView(): WorkItemView {
const list = {
axis: "forest" as WorkItemAxis,
status: "" as WorkItemStatus | "",
q: "",
page: 1,
};
let listData: WorkItemList | null = null;
let detail: WorkItemDetail | null = null;
let body: WorkItemBody | null = null;
/** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함 */
let stale = false;
let searchTimer = 0;
const fetchList = latestOnly(fetchWorkItems);
const fetchOne = latestOnly(fetchWorkItem);
// ── 왼쪽 목록 ──
const statusBox = el("div");
const search = createInputField({
type: "search",
placeholder: L("Z01_MasterData_Search"),
onInput: (value) => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
list.q = value.trim();
list.page = 1;
void loadList();
}, SEARCH_DELAY_MS);
},
});
const listBox = el("div", { className: "z01-wi__list" });
const pageInfo = el("span");
const prev = createButton({
label: L("Z01_MasterData_Prev"),
variant: "ghost",
onClick: () => {
list.page -= 1;
void loadList();
},
});
const next = createButton({
label: L("Z01_MasterData_Next"),
variant: "ghost",
onClick: () => {
list.page += 1;
void loadList();
},
});
const side = el("aside", {
className: "z01-wi__side",
children: [
statusBox,
search.root,
listBox,
el("div", { className: "z01-master__pager", children: [prev, pageInfo, next] }),
],
});
// ── 오른쪽 편집 ──
const title = el("h2", { className: "z01-master__title", text: L("Z01_WorkItem_Pick") });
const titleKey = el("span", { className: "z01-master__key" });
const action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") =>
createButton({ label: L(key), variant, onClick });
const saveButton = action("Z01_MasterData_Save", () => void save(), "filled");
const discardButton = action("Z01_MasterData_Discard", discard);
const resetButton = action("Z01_MasterData_Reset", () => void reset());
const reviewButton = action("Z01_WorkItem_Review", () => void save("reviewed"));
const draftStatus = el("span", { className: "z01-master__draft-status" });
const actions = el("div", {
className: "z01-master__toolbar",
children: [saveButton, discardButton, resetButton, reviewButton, draftStatus],
});
const head = el("div", { className: "z01-wi__head" });
const rowsBox = el("div");
const sourceBox = el("div");
const editor = el("section", {
className: "z01-wi__editor",
children: [el("div", { children: [title, titleKey] }), actions, head, rowsBox, sourceBox],
});
actions.hidden = true;
const root = el("section", { className: "z01-master__panel z01-wi", children: [side, editor] });
// 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음.
window.addEventListener("beforeunload", (event) => {
if (!detail || !body || !isDirty(detail, body) || !root.isConnected) return;
event.preventDefault();
event.returnValue = L("Z01_MasterData_Unsaved");
});
async function loadList(): Promise<void> {
listBox.classList.add("is-loading");
let data: WorkItemList | undefined;
try {
data = await fetchList({ ...list, size: PAGE_SIZE });
} catch (error) {
listBox.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (!data) return;
listBox.classList.remove("is-loading");
listData = data;
drawList();
}
function drawList(): void {
if (!listData) return;
const counts = listData.counts ?? {};
const all = STATUSES.reduce((sum, status) => sum + (counts[status] ?? 0), 0);
statusBox.replaceChildren(
createSelectField({
label: L("Z01_WorkItem_Status"),
options: [
{ value: "", text: `${L("Z01_WorkItem_Status_All")} (${all.toLocaleString("ko-KR")})` },
...STATUSES.map((status) => ({
value: status,
text: `${L(STATUS_LABEL[status])} (${(counts[status] ?? 0).toLocaleString("ko-KR")})`,
})),
],
value: list.status,
onChange: (value) => {
list.status = value as WorkItemStatus | "";
list.page = 1;
void loadList();
},
}).root,
);
listBox.replaceChildren(
...(listData.items.length
? listData.items.map(itemButton)
: [el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") })]),
);
const pages = pageCount(listData.total, PAGE_SIZE);
pageInfo.textContent = `${list.page} / ${pages} · ${listData.total.toLocaleString("ko-KR")}`;
prev.disabled = list.page <= 1;
next.disabled = list.page >= pages;
}
function itemButton(row: WorkItemListRow): HTMLElement {
// 저장 안 한 초안이 남은 공종은 ✎ — 다른 공종으로 넘어가도 초안은 탭에 남음.
const pending = hasDraft(scopeOf(row.work_item_key)) ? "✎ " : "";
const button = el("button", {
className: "z01-wi__item",
attrs: { type: "button", title: `${row.work_item_key} · ${row.path_name}` },
children: [
el("span", { className: "z01-wi__item-name", text: `${row.number} ${row.path_name}` }),
el("span", {
className: `z01-wi__status is-${row.status.replace("_", "-")}`,
text: L(STATUS_LABEL[row.status]),
}),
el("span", { className: "z01-master__key", text: `${pending}${row.row_count}` }),
],
});
button.classList.toggle("is-active", detail?.item.work_item_key === row.work_item_key);
button.addEventListener("click", () => void open(row.work_item_key));
return button;
}
async function open(key: string): Promise<void> {
let data: WorkItemDetail | undefined;
try {
data = await fetchOne(key);
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (data) take(data);
}
/** 받은 상세를 폄 — 같은 판 초안이 탭에 있으면 이어서. 저장·초기값 응답도 같은 모양이라 여기로. */
function take(data: WorkItemDetail): void {
detail = data;
stale = false;
const key = data.item.work_item_key;
body = readDraft<WorkItemBody>(scopeOf(key), data.item.revision) ?? freshBody(data);
drawEditor();
drawList();
}
function drawEditor(): void {
if (!detail || !body) return;
const item = detail.item;
title.textContent = `${item.number} ${item.path_name}`;
titleKey.textContent = item.work_item_key;
actions.hidden = false;
head.replaceChildren(...headFields(detail, body));
rowsBox.replaceChildren(
renderWorkRows(body, detail, (rebuild) => {
touched();
if (rebuild) drawEditor();
}),
);
sourceBox.replaceChildren(renderSource(detail));
drawActions();
}
/** 고침마다 — 초안을 탭에 두고(같아지면 지움) 단추·상태 글자를 맞춤. */
function touched(): void {
if (!detail || !body) return;
const dirty = isDirty(detail, body);
writeDraft(scopeOf(detail.item.work_item_key), detail.item.revision, dirty ? body : null);
drawActions();
drawList(); // 목록의 ✎ 표시를 맞춤
}
function drawActions(): void {
const dirty = Boolean(detail && body && isDirty(detail, body));
saveButton.disabled = !dirty || stale;
discardButton.disabled = !dirty && !stale;
// 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 먼저 저장·버리게 함.
resetButton.disabled = dirty;
resetButton.title = dirty ? L("Z01_MasterData_ResetBlocked") : "";
reviewButton.disabled = stale || (!dirty && detail?.item.status === "reviewed");
draftStatus.classList.toggle("is-warning", stale);
draftStatus.textContent = stale
? L("Z01_MasterData_Draft_Stale")
: L(dirty ? "Z01_WorkItem_Dirty" : "Z01_MasterData_Draft_None");
}
/** [저장] — status 를 주면 그 상태로 찍어 저장([검수 완료]). */
async function save(status?: WorkItemStatus): Promise<void> {
if (!detail || !body) return;
const key = detail.item.work_item_key;
const payload = workItemPayload(detail.item.revision, body);
if (status) payload.item.status = status;
showLoadingOverlay();
try {
const saved = await saveWorkItem(key, payload);
writeDraft(scopeOf(key), detail.item.revision, null);
showToast(L("Z01_MasterData_Saved"), "success");
take(saved);
void loadList();
} catch (error) {
if (error instanceof ApiError && error.status === 409) stale = true;
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
drawActions();
} finally {
hideLoadingOverlay();
}
}
function discard(): void {
if (!detail) return;
writeDraft(scopeOf(detail.item.work_item_key), detail.item.revision, null);
void open(detail.item.work_item_key);
}
async function reset(): Promise<void> {
if (!detail || (body && isDirty(detail, body))) return;
const key = detail.item.work_item_key;
const ok = await showConfirmDialog(L("Z01_WorkItem_ResetConfirm"), L("Z01_MasterData_Reset"));
if (!ok) return;
showLoadingOverlay();
try {
const restored = await resetWorkItem(key);
showToast(L("Z01_MasterData_ResetDone"), "success");
take(restored);
void loadList();
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
/** 머리 — 열쇠·번호·경로·상위·판은 읽기만 · 이름·기준 수량·기준 단위·상태는 고침. */
function headFields(shown: WorkItemDetail, draft: WorkItemBody): HTMLElement[] {
const item = shown.item;
const field = (label: LocaleKey, control: HTMLElement | string) =>
el("label", {
className: "z01-wi__field",
children: [el("span", { className: "z01-wi__label", text: L(label) }), control],
});
const readOnly = (label: LocaleKey, value: unknown) =>
field(label, value == null || value === "" ? "—" : String(value));
const editable = <K extends keyof WorkItemHead>(
label: LocaleKey,
key: K,
control: HTMLInputElement | HTMLSelectElement,
read: (raw: string) => WorkItemHead[K],
) => {
const wrap = field(label, control);
// 저장 전 고침(초안) · 저장했고 초기값과 다름(서버 changed_columns)
const mark = () => {
const pending = !same(draft.item[key], item[key]);
wrap.classList.toggle("is-draft", pending);
wrap.classList.toggle(
"is-changed",
!pending && Boolean(item.changed_columns?.includes(key)),
);
};
control.value = draft.item[key] == null ? "" : String(draft.item[key]);
control.addEventListener("change", () => {
draft.item[key] = read(control.value.trim());
mark();
touched();
});
mark();
return wrap;
};
const input = (type: "text" | "number") =>
el("input", {
className: "ui-input",
attrs: type === "number" ? { type, min: "0", step: "any" } : { type },
});
const status = el("select", { className: "ui-select" });
for (const each of STATUSES) status.append(new Option(L(STATUS_LABEL[each]), each));
return [
readOnly("Z01_WorkItem_Key", item.work_item_key),
readOnly("Z01_WorkItem_Number", item.number),
readOnly(
"Z01_WorkItem_Parent",
[item.parent_key, item.parent_mode].filter(Boolean).join(" · "),
),
readOnly("Z01_WorkItem_Edition", item.edition),
editable("Z01_WorkItem_Name", "name", input("text"), (raw) => raw),
editable("Z01_WorkItem_BasisQuantity", "basis_quantity", input("number"), (raw) =>
raw === "" ? null : Number(raw),
),
editable("Z01_WorkItem_BasisUnit", "basis_unit", input("text"), (raw) => raw || null),
editable("Z01_WorkItem_Status", "status", status, (raw) => raw as WorkItemStatus),
];
}
function show(axis: WorkItemAxis): void {
Object.assign(list, { axis, status: "", q: "", page: 1 });
search.input.value = "";
detail = null;
body = null;
stale = false;
title.textContent = L("Z01_WorkItem_Pick");
titleKey.textContent = "";
actions.hidden = true;
head.replaceChildren();
rowsBox.replaceChildren();
sourceBox.replaceChildren();
void loadList();
}
return { root, show };
}
@@ -1,374 +0,0 @@
/* =============================================================================
* Z01_MasterData_UI_WorkItem_Rows.ts
* () · ( ) · + []( )
*
* (`WorkItemBody`) [] (PLAN 1-0).
* (resource_key ) .
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
fetchBasePrices,
type ResourceKind,
type WorkItemDetail,
type WorkItemRow,
} from "./Z01_MasterData_Api_Fetch";
import { cellText, latestOnly } from "./Z01_MasterData_UI_Cells";
import {
addRow,
changedFields,
groupByVariant,
type DraftRow,
type WorkItemBody,
} from "./Z01_MasterData_UI_Draft";
type LocaleKey = Parameters<typeof L>[0];
const KIND_LABEL: Record<ResourceKind, LocaleKey> = {
labor: "Z01_WorkItem_Kind_Labor",
machine: "Z01_WorkItem_Kind_Machine",
material: "Z01_WorkItem_Kind_Material",
etc: "Z01_WorkItem_Kind_Etc",
};
/**
* resource_kind kind (·· · ).
* · `row_label` .
*/
type Findable = Exclude<ResourceKind, "etc">;
const FINDABLE: Record<Findable, { name: string; spec?: string }> = {
labor: { name: "occupation_name" },
machine: { name: "machine_name", spec: "specification" },
material: { name: "classification_name", spec: "specification" },
};
const FIND_SIZE = 20;
const SEARCH_DELAY_MS = 300;
const COLUMNS: LocaleKey[] = [
"Z01_WorkItem_Col_Kind",
"Z01_WorkItem_Col_Resource",
"Z01_WorkItem_Col_Spec",
"Z01_WorkItem_Col_Key",
"Z01_WorkItem_Col_Amount",
"Z01_WorkItem_Col_Unit",
"Z01_WorkItem_Col_Alt",
"Z01_WorkItem_Col_Ratio",
"Z01_WorkItem_Col_Raw",
"Z01_WorkItem_Col_Table",
"Z01_WorkItem_Col_Delete",
];
let tmpSeq = 0;
const tmpId = (): string => `new-${Date.now()}-${++tmpSeq}`;
/** rebuild = 줄 수·갈래·자원이 바뀜(다시 그림) · false = 칸 값만 바뀜(초점 지킴) */
export type RowsChanged = (rebuild: boolean) => void;
export function renderWorkRows(
body: WorkItemBody,
detail: WorkItemDetail,
changed: RowsChanged,
): HTMLElement {
const finder = buildFinder(() => changed(true));
const box = el("div", {
className: "z01-wi__rows",
children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Rows") })],
});
const groups = groupByVariant(body.rows);
// 줄이 하나도 없는 공종(입력 필요) — 갈래 없는 빈 묶음 하나로 [줄 추가] 를 세움.
if (!groups.length) groups.push({ variant: "", indexes: [] });
for (const group of groups) box.append(variantBlock(group.variant, group.indexes));
const variantInput = el("input", {
className: "ui-input",
attrs: { placeholder: L("Z01_WorkItem_VariantName") },
});
const addVariant = createButton({
label: L("Z01_WorkItem_AddVariant"),
variant: "ghost",
onClick: () => {
const name = variantInput.value.trim();
if (!name) return;
addRow(body, name, tmpId());
changed(true);
},
});
box.append(
el("div", { className: "z01-master__toolbar", children: [variantInput, addVariant] }),
finder.root,
);
return box;
function variantBlock(variant: string, indexes: number[]): HTMLElement {
const head = el("tr", { children: COLUMNS.map((key) => el("th", { text: L(key) })) });
const table = el("table", {
className: "z01-master__grid z01-wi__grid",
children: [
el("thead", { children: [head] }),
el("tbody", { children: indexes.map((index) => rowLine(body.rows[index])) }),
],
});
const add = createButton({
label: L("Z01_MasterData_AddRow"),
variant: "ghost",
onClick: () => {
addRow(body, variant, tmpId());
changed(true);
},
});
return el("div", {
className: "z01-wi__group",
children: [
el("h4", { className: "z01-wi__variant", text: variant || L("Z01_WorkItem_NoVariant") }),
el("div", { className: "z01-master__grid-wrap", children: [table] }),
add,
],
});
}
function rowLine(row: DraftRow): HTMLElement {
const tr = el("tr");
const cells: [keyof WorkItemRow, HTMLElement][] = [];
// 고친 칸 표시 — 저장 전(초안 · 새 줄은 줄째) · 저장했고 초기값과 다름(서버 changed_columns)
const saved = row.changed_columns ?? [];
tr.classList.toggle("is-added", Boolean(row.is_added));
const mark = (): void => {
const diff = changedFields(row, detail.rows);
tr.classList.toggle("is-new", diff === "added");
tr.classList.toggle("is-deleted", Boolean(row.deleted));
for (const [field, td] of cells) {
const draft = diff !== "added" && diff.has(field);
td.classList.toggle("is-draft", draft);
td.classList.toggle("is-changed", !draft && saved.includes(field));
}
};
const cell = (field: keyof WorkItemRow, ...children: (HTMLElement | string)[]) => {
const td = el("td", { children });
cells.push([field, td]);
tr.append(td);
return td;
};
const set = (field: keyof WorkItemRow, value: unknown): void => {
(row as unknown as Record<string, unknown>)[field] = value;
mark();
changed(false);
};
const text = (field: "resource_name" | "resource_spec" | "amount_unit") => {
const input = el("input", { className: "ui-input" });
input.value = row[field] ?? "";
input.addEventListener("change", () => set(field, input.value.trim()));
return input;
};
const number = (
field: "amount" | "alternative_amount" | "group_ratio_pct",
): HTMLInputElement => {
const input = el("input", {
className: "ui-input",
attrs: { type: "number", min: "0", step: "any" },
});
input.value = row[field] == null ? "" : String(row[field]);
input.addEventListener("change", () => {
const raw = input.value.trim();
set(field, raw === "" ? null : Number(raw));
});
return input;
};
const kind = el("select", { className: "ui-select" });
for (const each of Object.keys(KIND_LABEL) as ResourceKind[]) {
kind.append(new Option(L(KIND_LABEL[each]), each));
}
kind.value = row.resource_kind;
// 종류가 바뀌면 이은 열쇠는 다른 표의 것 — 풀어 두고 다시 찾게 함.
kind.addEventListener("change", () => {
row.resource_kind = kind.value as ResourceKind;
row.resource_key = null;
changed(true);
});
cell("resource_kind", kind);
cell("resource_name", text("resource_name"));
cell("resource_spec", text("resource_spec"));
const key = row.resource_key
? el("span", { text: row.resource_key })
: el("span", { className: "z01-wi__unmatched", text: L("Z01_WorkItem_Unmatched") });
const find = createButton({
label: L("Z01_WorkItem_Find"),
variant: "ghost",
disabled: row.resource_kind === "etc",
onClick: () => finder.open(row),
});
cell("resource_key", key, find);
cell("amount", number("amount"));
cell("amount_unit", text("amount_unit"));
cell("alternative_amount", number("alternative_amount"));
cell("group_ratio_pct", number("group_ratio_pct"));
cell(
"raw_cell",
el("span", { text: row.raw_cell ?? "", attrs: { title: row.raw_cell ?? "" } }),
);
cell("pum_table_id", row.pum_table_id ?? "");
const drop = createButton({
label: L(row.deleted ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow"),
variant: "ghost",
onClick: () => {
// 새 줄은 바로 뺌 · 서버 줄은 줄 긋고 [저장] 때 뺌(되살릴 수 있게)
if (row.id === undefined) body.rows.splice(body.rows.indexOf(row), 1);
else row.deleted = !row.deleted;
changed(true);
},
});
tr.append(el("td", { children: [drop] }));
mark();
return tr;
}
}
/** 자원 찾기 상자 — 기초단가 검색 API 로 찾아 한 줄을 고르면 그 줄에 열쇠·이름·규격을 넣음. */
function buildFinder(picked: () => void): { root: HTMLElement; open: (row: DraftRow) => void } {
let target: DraftRow | null = null;
let timer = 0;
const search = latestOnly(fetchBasePrices);
const title = el("strong");
const kind = el("select", { className: "ui-select" });
for (const each of Object.keys(FINDABLE) as Findable[]) {
kind.append(new Option(L(KIND_LABEL[each]), each));
}
const input = el("input", {
className: "ui-input",
attrs: { type: "search", placeholder: L("Z01_MasterData_Search") },
});
const results = el("div", { className: "z01-wi__found" });
const close = createButton({
label: L("Z01_WorkItem_FindClose"),
variant: "ghost",
onClick: () => {
root.hidden = true;
},
});
const root = el("div", {
className: "z01-wi__finder",
children: [
title,
el("div", { className: "z01-master__toolbar", children: [kind, input, close] }),
results,
],
});
root.hidden = true;
kind.addEventListener("change", () => void run());
input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), SEARCH_DELAY_MS);
});
async function run(): Promise<void> {
const from = kind.value as Findable;
let data;
try {
data = await search(from, { page: 1, size: FIND_SIZE, q: input.value.trim() });
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (!data) return;
const fields = FINDABLE[from];
results.replaceChildren(
...(data.rows.length
? data.rows.map((row) => {
const name = cellText(row[fields.name]);
const spec = fields.spec ? cellText(row[fields.spec]) : "";
const button = el("button", {
className: "z01-wi__item",
attrs: { type: "button" },
children: [
el("span", { className: "z01-wi__item-name", text: `${name} ${spec}`.trim() }),
el("span", { className: "z01-master__key", text: cellText(row["@id"]) }),
],
});
button.addEventListener("click", () => {
if (!target) return;
target.resource_kind = from;
target.resource_key = String(row["@id"]);
target.resource_name = name;
target.resource_spec = spec;
root.hidden = true;
picked();
});
return button;
})
: [el("p", { className: "z01-master__note", text: L("Z01_WorkItem_FindNone") })]),
);
}
function open(row: DraftRow): void {
target = row;
kind.value = row.resource_kind === "etc" ? "material" : row.resource_kind;
input.value = row.resource_name;
title.textContent = `${L("Z01_WorkItem_FindTitle")}${row.resource_name || row.raw_cell || ""}`;
results.replaceChildren();
root.hidden = false;
root.scrollIntoView({ block: "nearest" });
void run();
}
return { root, open };
}
/** 품셈 원문 표 + [주] — 읽기 전용(값을 고칠 때 옆에 두고 봄). */
export function renderSource(detail: WorkItemDetail): HTMLElement {
const box = el("div", {
className: "z01-wi__source",
children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Source") })],
});
if (!detail.source.length) {
box.append(el("p", { className: "z01-master__note", text: L("Z01_WorkItem_NoSource") }));
}
for (const table of detail.source) {
const caption = [table.pum_table_id, table.section, table.pum_form].filter(Boolean).join(" · ");
const heads = cells(table.condition_note);
const rows = Array.isArray(table.raw_row) ? (table.raw_row as unknown[]) : [];
const grid = el("table", { className: "z01-master__grid" });
if (heads.length) {
grid.append(
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
}),
);
}
grid.append(
el("tbody", {
children: rows.map((row) =>
el("tr", {
children: cells(row).map((c) => el("td", { text: c, attrs: { title: c } })),
}),
),
}),
);
box.append(
el("h4", { className: "z01-wi__variant", text: caption }),
el("div", { className: "z01-master__grid-wrap", children: [grid] }),
);
}
const notes = detail.notes ?? [];
if (notes.length) {
box.append(
el("h4", { className: "z01-wi__variant", text: L("Z01_WorkItem_Notes") }),
el("ul", {
className: "z01-wi__notes",
children: notes.map((note) => el("li", { text: noteText(note) })),
}),
);
}
return box;
}
/** 원문 칸 — 목록이면 칸마다 · 글자 하나면 한 칸. */
function cells(value: unknown): string[] {
if (Array.isArray(value)) return value.map((each) => cellText(each));
return value == null ? [] : [cellText(value)];
}
function noteText(note: unknown): string {
if (note && typeof note === "object" && "text" in note) return cellText(note.text);
return cellText(note);
}
-132
View File
@@ -7,15 +7,11 @@
빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
`@id` = 불변 열쇠(FW-·CW-) 없는 줄은 목차 코드로 서고 알림에 드러냄.
읽기만 고칠 없음(원문 풀기 8-7 ). 원본 `work_item_code`(FP-) B08·B09 건드림.
공종 편집(DB 정본 · 2026-09-18 PLAN 1-0) 아래 공종 편집 . `master_work_item`·`_row`
고치는 규칙만 순수 함수로(DB 없이 시험) · SQL `Z01_MasterData_Repository_WorkItems`.
"""
from __future__ import annotations
import json
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -296,131 +292,3 @@ def extra(kind: str) -> dict[str, Any]:
"units": len(rows),
},
}
# ── 공종 편집 — DB 표 `master_work_item`·`master_work_item_row` 를 고치는 규칙(PLAN 1-0 계약) ──
STATUSES = ("auto_ok", "partial", "needs_input", "not_item", "reviewed")
#: 관리자가 고치는 칸 — [초기값으로] 도 이 칸만 seed 에서 되살림
HEAD_FIELDS = ("name", "basis_quantity", "basis_unit", "status")
ROW_FIELDS = (
"variant",
"resource_kind",
"resource_key",
"resource_name",
"resource_spec",
"amount",
"amount_unit",
"alternative_amount",
"group_ratio_pct",
"raw_cell",
"pum_table_id",
)
_HIDDEN = ("id", "work_item_id", "seed", "source", "notes", "is_deleted")
def revision_scope(key: str) -> str:
return f"work_item:{key}"
def _same(a: Any, b: Any) -> bool:
"""칸 값이 같은가 — 숫자는 값으로(DB `1.500000` = 요청 `1.5` = seed `1.5`) · 빈 글자 = NULL."""
numbers = (int, float, Decimal)
if isinstance(a, numbers) and isinstance(b, numbers):
return Decimal(str(a)) == Decimal(str(b))
return (None if a == "" else a) == (None if b == "" else b)
def changed_columns(current: dict[str, Any], seed: dict[str, Any] | None, fields) -> list[str]:
"""초기값과 다른 칸 — 관리자 추가 줄(seed 없음)은 빈 목록(`is_added` 로 드러남)."""
if seed is None:
return []
return [f for f in fields if f in seed and not _same(current.get(f), seed[f])]
def detail(head: dict[str, Any], rows: list[dict[str, Any]], revision: int) -> dict[str, Any]:
"""GET 응답 — 머리(+revision) · 구성 줄 · 원문 표 · [주]. seed 는 안 내보내고 고친 칸 이름만."""
seed = head.get("seed")
return {
"item": {
**{k: v for k, v in head.items() if k not in _HIDDEN},
"revision": revision,
"changed_columns": changed_columns(head, seed, HEAD_FIELDS),
},
"rows": [
{
**{k: v for k, v in row.items() if k not in _HIDDEN or k == "id"},
"is_added": row.get("seed") is None,
"changed_columns": changed_columns(row, row.get("seed"), ROW_FIELDS),
}
for row in rows
],
"source": head.get("source") or [],
"notes": head.get("notes") or [],
}
def check_rows(
rows: list[dict[str, Any]], existing_ids: set[int], known_keys: set[str]
) -> list[str]:
"""[저장] 서버 검사 중 DB 와 맞춰 볼 것 — 모양(숫자 ≥0 · 종류 · 모르는 칸)은 요청 모델이 봄."""
errors, seen = [], set()
for number, row in enumerate(rows, 1):
row_id = row.get("id")
if row_id is not None:
if row_id not in existing_ids:
errors.append(f"{number}번째 줄: 이 공종에 없는 줄(id {row_id})")
elif row_id in seen:
errors.append(f"{number}번째 줄: 같은 줄(id {row_id})이 두 번 옴")
seen.add(row_id)
key = row.get("resource_key")
if key is not None and key not in known_keys:
errors.append(f"{number}번째 줄: 기초단가에 없는 자원 열쇠 {key}")
return errors
def plan_rows(
existing: list[dict[str, Any]], incoming: list[dict[str, Any]]
) -> dict[str, list[Any]]:
"""전체 줄 보내기 → 쓸 것. existing = 지금 살아 있는 줄 · incoming 순서 = sort_order.
id 있음 수정(바뀐 줄만) · id 없음 추가 · 빠진 초기값 줄은 숨김(`is_deleted` ·
[초기값으로] 살림) · 관리자 추가 줄은 지움.
"""
by_id = {row["id"]: row for row in existing}
updates, inserts = [], []
for order, row in enumerate(incoming):
values = {f: row.get(f) for f in ROW_FIELDS} | {"sort_order": order}
old = by_id.get(row.get("id"))
if old is None:
inserts.append(values)
elif any(not _same(old.get(f), v) for f, v in values.items()):
updates.append({"id": old["id"], **values})
kept = {row.get("id") for row in incoming}
gone = [row for row in existing if row["id"] not in kept]
return {
"updates": updates,
"inserts": inserts,
"hide": [row["id"] for row in gone if row.get("seed") is not None],
"drop": [row["id"] for row in gone if row.get("seed") is None],
}
def reset_plan(head: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
"""[초기값으로] — 머리·줄을 seed 로(seed 에 있는 칸만) · 숨긴 초기값 줄도 살림 · 추가 줄은 지움.
rows = 숨긴 줄까지 전부. 쓰기 모양은 `plan_rows` 같음(+ `head`).
"""
seed = head.get("seed") or {}
restore = (*ROW_FIELDS, "sort_order")
return {
"head": {f: seed[f] for f in HEAD_FIELDS if f in seed},
"updates": [
{"id": row["id"], **{f: row["seed"][f] for f in restore if f in row["seed"]}}
for row in rows
if row.get("seed") is not None
],
"inserts": [],
"hide": [],
"drop": [row["id"] for row in rows if row.get("seed") is None],
}
-2
View File
@@ -76,7 +76,6 @@ from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
router as b09_material_prices_router,
)
from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router
from Z01_MasterData.Z01_MasterData_Router_WorkItems import router as z01_work_items_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
@@ -660,7 +659,6 @@ app.include_router(b09_material_prices_router, dependencies=protected_with_compa
# Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01).
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(z01_master_data_router, dependencies=system_admin_only)
app.include_router(z01_work_items_router, dependencies=system_admin_only)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
-92
View File
@@ -1,92 +0,0 @@
"""Z01 기초단가 저장소 가짜 — DB 없이 API 시험(PLAN 1-2 · 2026-09-18).
`install(monkeypatch)` `Z01_MasterData_Repository_BasePrices` 함수 다섯을 바꿔 끼움.
주입 = 파일 원본 (`base_rows`) 그대로 · '@' (`@axis`·`@source`) **빼고** 담음
주입기가 안쪽 칸을 빼도 서버가 서는지 함께 . 판번호 규칙(다르면 409 · 쓰면 +1) 진짜와 같게.
"""
from __future__ import annotations
from typing import Any
import pytest
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
class FakeRepo:
def __init__(self) -> None:
self.tables: dict[str, list[dict[str, Any]]] = {}
self.revisions: dict[str, int] = {}
self.saved_by: list[Any] = []
def table(self, kind: str) -> list[dict[str, Any]]:
if kind not in self.tables:
self.tables[kind] = []
for row in base_prices.base_rows(kind):
data = {k: v for k, v in row.items() if not k.startswith("@")}
self.tables[kind].append(
{"row_key": row["@id"], "data": data, "seed": dict(data), "deleted": False}
)
return self.tables[kind]
def by_key(self, kind: str) -> dict[str, dict[str, Any]]:
return {r["row_key"]: r for r in self.table(kind)}
async def load(self, kind: str) -> list[dict[str, Any]]:
return [
{"row_key": r["row_key"], "data": dict(r["data"]), "seed": r["seed"]}
for r in self.table(kind)
if not r["deleted"]
]
async def revision(self, kind: str) -> int:
return self.revisions.get(kind, 0)
async def counts(self) -> dict[str, int]:
return {
kind: sum(not r["deleted"] for r in self.table(kind))
for kind in base_prices.KINDS
if kind not in work_items.KINDS
}
async def save(self, kind, base_revision, changed, added, deleted, by) -> int:
current = self.revisions.get(kind, 0)
if current != base_revision:
raise repo.RevisionConflict(current)
rows = self.by_key(kind)
for key, data in changed.items():
if not rows[key]["deleted"]:
rows[key]["data"] = dict(data)
for key, data in added.items():
self.table(kind).append(
{"row_key": key, "data": dict(data), "seed": None, "deleted": False}
)
for key in deleted:
rows[key]["deleted"] = True
self.revisions[kind] = current + 1
self.saved_by.append(by)
return current + 1
async def reset(self, kind, row_keys, by) -> int:
rows = self.by_key(kind)
missing = sorted(set(row_keys) - set(rows))
if missing:
raise repo.UnknownRows(missing)
for key in row_keys:
row = rows[key]
if row["seed"] is None:
row["deleted"] = True
else:
row["data"], row["deleted"] = dict(row["seed"]), False
self.revisions[kind] = self.revisions.get(kind, 0) + 1
return self.revisions[kind]
def install(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
fake = FakeRepo()
for name in ("load", "revision", "counts", "save", "reset"):
monkeypatch.setattr(repo, name, getattr(fake, name))
return fake
@@ -10,12 +10,10 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from helper_z01_fake_repo import install
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
@@ -30,13 +28,11 @@ def labels() -> dict:
@pytest.fixture(scope="module")
def shown() -> dict[str, list[dict]]:
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다(DB 는 파일을 주입한 가짜)."""
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다."""
out = {}
with pytest.MonkeyPatch.context() as patch:
install(patch)
for kind in base_prices.KINDS:
columns = base_prices._columns(kind, asyncio.run(base_prices.rows(kind)))
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
for kind in base_prices.KINDS:
columns = base_prices._columns(kind, base_prices.rows(kind))
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
return out
@@ -103,8 +99,8 @@ def test_요율_축이_한_칸으로_안_합쳐졌다(shown):
def test_요율_줄_열쇠에_축이_살아_있다():
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 주입 열쇠(row_key)가 흔들린다."""
rows = base_prices.base_rows("rate")
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다."""
rows = base_prices.rows("rate")
multi = [r for r in rows if "/" in str(r["@id"])]
assert multi, "축이 든 줄 열쇠가 하나도 없다"
assert any("=" in str(r["@id"]) for r in multi)
@@ -113,7 +109,7 @@ def test_요율_줄_열쇠에_축이_살아_있다():
def test_기계_시간당_단가는_계산값으로_잠긴다(shown):
"""밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다)."""
meta = base_prices.table("machine", base_prices.base_rows("machine"), page=1, size=1)
meta = base_prices.table("machine", page=1, size=1)
assert set(meta["formula"]) == {
"hourly_loss_krw",
"hourly_fuel_krw",
@@ -159,15 +155,15 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown):
def test_줄_열쇠가_서버_축과_같다(labels):
"""⭐ 줄을 붙드는 끈 — 파일 원본이 줄마다 `@axis` 로 내는 축이 곧 열쇠(주입 row_key)다.
""" 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다.
이름표가 축을 빠뜨리면 주입 같은 줄을 알아본.
이름표가 축을 빠뜨리면 고친 값이 갱신 주인을 잃는.
"""
declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]}
bad = []
for kind in base_prices.KINDS:
axes = set()
for row in base_prices.base_rows(kind):
for row in base_prices.rows(kind):
axes |= set(row.get("@axis") or ())
if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다
continue
@@ -178,13 +174,13 @@ def test_줄_열쇠가_서버_축과_같다(labels):
def test_구간_이름은_원문_표기_그대로다():
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 주입한 줄을 못 알아본다."""
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다."""
for kind in ("masonry_slope", "masonry_back_length"):
brackets = {str(r.get("height_bracket")) for r in base_prices.base_rows(kind)}
brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)}
assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets))
classes = {
str(r.get("class"))
for r in base_prices.base_rows("masonry_class")
for r in base_prices.rows("masonry_class")
if r.get("table") == "boulder_diameter"
}
assert classes == {"40~60", "60~80", "80~100"}, sorted(classes)
@@ -202,13 +198,13 @@ def test_축_칸은_못_고친다():
"""축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다."""
bad = []
for kind in base_prices.KINDS:
rows = base_prices.base_rows(kind)
rows = base_prices.rows(kind)
axes = set()
for row in rows:
axes |= set(row.get("@axis") or ())
if not axes:
continue
meta = base_prices.table(kind, rows, page=1, size=1)
meta = base_prices.table(kind, page=1, size=1)
for axis in axes:
if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE:
bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다")
@@ -232,11 +228,10 @@ def test_기초단가_상자_이름이_이름표에_있다(labels):
assert named["pumsem_basis"]["name_ko"] == "품셈 기준"
def test_상자마다_kind_가_있다(monkeypatch):
def test_상자마다_kind_가_있다():
"""빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다."""
install(monkeypatch)
counted: dict[str, int] = {}
for entry in asyncio.run(base_prices.kinds()):
for entry in base_prices.kinds():
counted[entry["group"]] = counted.get(entry["group"], 0) + 1
assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다"
assert entry["rows"] > 0, entry["kind"]
+287 -267
View File
@@ -1,27 +1,23 @@
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2).
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시).
: 정본 = DB `master_base_price`(data = 지금 · seed = 초기값) · 원본 파일 `resources/data_*` **주입 재료로 읽기만** ·
[저장] = {base_revision, edits, added, deleted} 판이 다르면 409 · 하나라도 틀리면 아무것도 ·
줄마다 changed_columns(초기값과 다른 )·is_added(관리자 추가 ) · [초기값으로] = data := seed(추가 줄은 지움).
(브레인 못박음): 원본 `resources/data_*` **읽기만** · 고친 값은 덮개
`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 파일)에만 · 읽을 원본 위에 얹음 ·
덮개 줄마다 original(원래 )·source(원본 파일) 원본이 바뀌면 원본 바뀜 · 줄이 사라지면 주인 없음(버리지 않음) ·
기계 시간당 단가는 계산값 editable 에서 빼고 고치나(locked) · 밑값(취득가·손료계수·노임·유가) 고치면 따라 바뀜.
DB `helper_z01_fake_repo` 가짜로 저장소 SQL 흐름(잠금·되돌림) 아래 가짜 커넥션으로 .
"""
from __future__ import annotations
import asyncio
import hashlib
import json
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import FakeRepo, install
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
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]
@@ -40,12 +36,14 @@ SOURCES = [
@pytest.fixture
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
return install(monkeypatch)
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(fake: FakeRepo) -> TestClient:
def client(store: Path) -> TestClient:
from common_util.common_util_auth import verify_session
app = FastAPI()
@@ -65,20 +63,8 @@ def _row(client: TestClient, kind: str, row_id: str) -> dict:
return next(r for r in rows if r["@id"] == row_id)
def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None):
if base is None:
base = _get(client, kind, size=1)["revision"]
body = {
"base_revision": base,
"edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits],
"added": [{"data": d} for d in added],
"deleted": list(deleted),
}
return client.put(f"/api/master-data/base-prices/{kind}", json=body)
def _edit(client: TestClient, kind: str, row_id: str, column: str, value):
return _save(client, kind, edits=[(row_id, column, value)])
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:
@@ -95,15 +81,13 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No
for kind, (total, editable) in expected.items():
table = _get(client, kind, size=500)
assert table["total"] == total and table["editable"] == editable, kind
assert table["status"] == "success" and table["revision"] == 0, kind
ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]]
assert len(ids) == len(set(ids)), kind
# 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감
assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"])
assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")}
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 not columns & {"changed_columns", "is_added"}, 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설계사(기계)"
@@ -132,8 +116,8 @@ def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를
"hourly_total_krw" in row["@formula"]
and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"]
)
res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1)
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"]
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:
@@ -162,23 +146,38 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien
assert checked >= 5
def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None:
def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None:
before = {p: _sha(p) for p in SOURCES}
res = _save(
client,
"labor",
edits=[("labor_const/1001", "daily_wage_krw", 230000)],
base=0,
)
res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
assert res.status_code == 200, res.text
assert res.json() == {"status": "success", "revision": 1, "added": []}
row = _row(client, "labor", "labor_const/1001")
assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"]
stored = fake.by_key("labor")["labor_const/1001"]
assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907
assert fake.saved_by == [42] # 누가 고쳤나
assert _get(client, "labor", size=1)["revision"] == 1
assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다
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
@@ -186,113 +185,139 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien
base = _row(client, "machine", "0101-0007")
operator = base["operator_occupation_code"]
wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"]
assert _edit(
client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000
).is_success
assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success
price = base["price_thousand_krw"]
assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success
_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)
# 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜
assert after["changed_columns"] == ["price_thousand_krw"]
price = base["price_thousand_krw"]
assert after["@overrides"] == {
"price_thousand_krw": {
"original": price,
"value": price * 2,
"state": overrides.EDITED,
"current": price,
}
} # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜
def test_계산_칸은_DB_에_안_(client: TestClient, fake: FakeRepo) -> None:
"""기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴."""
_edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001)
data = fake.by_key("machine")["0101-0007"]["data"]
assert data["loss_coefficient_per_hour"] == 0.001
assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data)
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, fake: FakeRepo) -> None:
good = ("labor_const/1001", "daily_wage_krw", 230000)
for bad, status in (
(("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸
(("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값
(("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄
):
res = _save(client, "labor", edits=[good, bad])
assert res.status_code == status, (bad, res.text)
assert res.json()["status"] == "error"
res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"])
assert res.status_code == 404
assert fake.revisions == {} and fake.saved_by == []
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
def test_낡은_판으로_저장하면_409(
client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch
def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김(
client: TestClient, store: Path
) -> None:
assert _save(
client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0
).is_success
stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"]
assert _row(client, "labor", "labor_const/1002")["changed_columns"] == []
# 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄)
async def old_revision(kind: str) -> int:
return 0
monkeypatch.setattr(repo, "revision", old_revision)
raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert raced.status_code == 409 and fake.revisions["labor"] == 1
def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None:
data = {"specification": "ZZ시험 규격, 특수", "unit": "", "price_krw": 1234}
res = _save(client, "material", added=[data])
assert res.status_code == 200, res.text
(key,) = res.json()["added"]
assert key.startswith("added/") and res.json()["revision"] == 1
table = _get(client, "material", size=1, page=7000)
assert table["total"] == 7000
row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례)
assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, [])
assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수"
assert _get(client, "material", q="ZZ시험")["total"] == 1
# 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침
assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success
assert _edit(client, "material", "10023392", "specification", "x").status_code == 400
for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}):
assert _save(client, "material", added=[bad]).status_code == 400, bad
assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄
gone = _save(client, "material", deleted=[key, "10023392"])
assert gone.status_code == 200
ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]}
assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998
assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게)
def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None:
_edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000)
added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}])
(key,) = added.json()["added"]
_save(client, "labor", deleted=["labor_const/1002"])
res = client.post(
"/api/master-data/base-prices/labor/reset",
json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]},
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",
)
assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4}
back = _row(client, "labor", "labor_const/1001")
assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == []
assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림
assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움
missing = client.post(
"/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]}
# 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이
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
)
assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"]
empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []})
assert empty.status_code == 422 # 통째로 되돌리기 막음
def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
rate = _get(client, "rate", size=1)
assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"])
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
@@ -300,18 +325,16 @@ def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
def test_막는_자리(client: TestClient) -> None:
assert _save(client, "wage", base=0).status_code == 404
assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫
locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸
assert locked.status_code == 400 and "공표 원문" in locked.json()["message"]
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 = {
"base_revision": 0,
"edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}],
}
body = json.dumps({"values": {"daily_wage_krw": bad}}).encode()
res = client.put(
"/api/master-data/base-prices/labor",
content=json.dumps(body).encode(),
"/api/master-data/base-prices/labor/labor_const/1001",
content=body,
headers={"content-type": "application/json"},
)
assert res.status_code in (400, 422), bad
@@ -321,20 +344,9 @@ def test_막는_자리(client: TestClient) -> None:
if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")
)
assert (
_edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400
) # 그 줄엔 요율 칸 없음
_put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400
) # 그 줄엔 요율 칸 없음
assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56")
many = [("labor_const/1001", "daily_wage_krw", 1)] * 501
assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지
extra = client.put(
"/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}}
)
assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함)
old = client.put(
"/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}}
)
assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐
assert client.get("/api/master-data/overrides").status_code == 404
def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None:
@@ -352,6 +364,61 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) ->
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",
@@ -409,6 +476,12 @@ def test_우리_판은_출처표_글자가_아니라_파일에서_읽음(
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(
@@ -467,8 +540,8 @@ def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None:
assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐
def test_고친_값으로_세움(client: TestClient) -> None:
_edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000)
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
@@ -478,107 +551,54 @@ def test_sort_가_없으면_원본_차례(client: TestClient) -> None:
assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"
def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None:
"""카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄."""
data = {
"machine_name": "시험 굴삭기",
"price_thousand_krw": 100000,
"loss_coefficient_per_hour": 0.0001,
}
(key,) = _save(client, "machine", added=[data]).json()["added"]
row = _get(client, "machine", size=1, page=614)["rows"][0]
assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000)
assert row["hourly_total_krw"] is None and row["hourly_note"]
assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸
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)
# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ──────────────────────────────
class _Cursor:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql: str, params=()) -> None:
self.db.sql.append(" ".join(sql.split()))
async def fetchone(self):
return (self.db.revision,)
async def fetchall(self):
return [(key,) for key in self.db.keys]
class _Connection:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def cursor(self, *_):
return _Cursor(self.db)
async def begin(self) -> None:
self.db.log.append("begin")
async def commit(self) -> None:
self.db.log.append("commit")
async def rollback(self) -> None:
self.db.log.append("rollback")
class _Pool:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def acquire(self):
return self
async def __aenter__(self):
return _Connection(self.db)
async def __aexit__(self, *_):
return False
def _writes(db: SimpleNamespace) -> list[str]:
return [
s
for s in db.sql
if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price"))
]
def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=4, keys=[])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.RevisionConflict):
asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5
assert db.log == ["begin", "commit"]
lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE"))
bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision"))
writes = [db.sql.index(s) for s in _writes(db)]
assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1
assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만
def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.UnknownRows):
asyncio.run(repo.reset("labor", ["a", "없음"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3
assert db.log == ["begin", "commit"]
assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "]
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
)
+28 -32
View File
@@ -19,9 +19,9 @@ ROOT = Path(__file__).resolve().parents[2]
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import install
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
@@ -40,10 +40,10 @@ NEW_KINDS = (
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
install(monkeypatch) # DB 가짜 — 파일 원본을 초기값으로 주입한 모양
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"}
@@ -69,13 +69,13 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None
table = _get(client, kind, size=500)
assert table["total"] == len(table["rows"]) > 0, kind
ids = _ids(table)
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 (kind, row_key) 한 줄이 둘을 덮
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 # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로)
@@ -165,39 +165,35 @@ def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None:
)
def _save(client: TestClient, kind: str, row_key: str, column: str, value):
revision = _get(client, kind, size=1)["revision"]
return client.put(
f"/api/master-data/base-prices/{kind}",
json={
"base_revision": revision,
"edits": [{"row_key": row_key, "column": column, "value": value}],
},
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}},
)
def test_고치기도_같은_길(client: TestClient) -> None:
res = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "m3_per_m2", 0.1)
assert res.status_code == 200, res.text
rows = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]}
row = rows["wedge_stone_m3_per_m2/깬잡석/25"]
assert row["m3_per_m2"] == 0.1 and row["changed_columns"] == ["m3_per_m2"]
assert [k for k, r in rows.items() if r["changed_columns"]] == [
"wedge_stone_m3_per_m2/깬잡석/25"
]
blocked = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "stone_kind", "딴 돌")
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 not {"@axis", "@source", "@data"} & set(row) # 안쪽 칸은 안 내보냄
assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄
# 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함
# ⚠ 주입 data 에 `@axis` 가 없어도 막힘(축은 파일 원본에서 읽음 · 가짜 주입은 '@' 칸을 뺌)
axis_edit = _save(
client, "formwork_reuse", "reuse_ratio_pct/plywood_material_pct/2", "reuse_count", 3
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 = _save(client, "formwork_reuse", "reuse_by_class/복잡한 구조", "reuse_count", 3)
assert value_edit.status_code == 200
reuse = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]}
assert reuse["reuse_by_class/복잡한 구조"]["reuse_count"] == 3
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:
-184
View File
@@ -1,184 +0,0 @@
"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18).
기초단가 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 고침은 보냄 · 줄은 data 보냄
같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움
공종 보낼 몸은 지울 줄을 빼고 화면 표시 (tmp·deleted) (빠지면 삭제 = 전체 보내기)
고친 것이 없으면 dirty 아님 · 고치면 dirty · 줄은 added · 고친 칸만 짚음
갈래는 처음 나온 차례로 묶음 · 줄은 갈래 바로 (보낸 차례 = sort_order)
보낼 줄은 id + 계약 열하나만 GET 전용 (sort_order·is_added·changed_columns) 실리면 422
TS 실제로 돌린다(`test_z01_master_cells` 같은 방식).
"""
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_Draft.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
// 브라우저 sessionStorage 대역
const store = new Map();
globalThis.sessionStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
const {
BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload,
} = await import("./Z01_MasterData_UI_Draft.js");
// 기초단가
const base = new BaseDraft("base:labor", 7);
base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907);
base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 쌓임
base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2);
base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 빠짐
base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4);
base.toggleDelete("labor_const/9999");
const added = base.addRow();
base.setAdded(added.tmp, "occupation_name", "새 직종");
base.setAdded(added.tmp, "daily_wage_krw", 300000);
base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을
const payload = base.payload();
const count = base.count;
const stored = JSON.parse(store.get("z01-draft:base:labor"));
// 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움
const again = new BaseDraft("base:labor", 7);
const restored = again.payload();
const newer = new BaseDraft("base:labor", 8);
const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") };
const toggled = new BaseDraft("base:oil", 1);
toggled.toggleDelete("x");
toggled.toggleDelete("x");
const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") };
// 공종
const row = (id, variant, extra = {}) => ({
id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부",
resource_spec: "", amount: 0.5, amount_unit: "", alternative_amount: null,
group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1",
// GET 전용 PUT 실리면 422(extra=forbid)
sort_order: id, is_added: false, changed_columns: [], ...extra,
});
const detail = {
item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목",
path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1,
basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 },
rows: [row(1, ""), row(2, ""), row(3, "")],
source: [], notes: [],
};
const body = freshBody(detail);
const clean = isDirty(detail, body);
body.rows[0].amount = 0.7;
body.rows[1].deleted = true;
addRow(body, "", "new-1");
body.item.status = "reviewed";
const sent = workItemPayload(detail.item.revision, body);
writeFileSync(process.argv[2], JSON.stringify({
payload, count, stored,
restored, dropped, emptied,
clean,
dirty: isDirty(detail, body),
sent,
changed: [...changedFields(body.rows[0], detail.rows)],
addedRow: changedFields(body.rows[2], detail.rows),
untouched: [...changedFields(body.rows[3], detail.rows)],
groups: groupByVariant(body.rows),
freshUntouched: freshBody(detail).rows[0].amount,
}));
"""
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"))
# ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만
assert got["payload"] == {
"base_revision": 7,
"edits": [
{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000},
],
"added": [{"data": {"occupation_name": "새 직종"}}],
"deleted": ["labor_const/9999"],
}
# 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1
assert got["count"] == 4
assert got["stored"]["revision"] == 7
# ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐
assert got["restored"] == got["payload"]
assert got["dropped"] == {"count": 0, "left": False}
assert got["emptied"] == {"count": 0, "left": False}
# ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림
assert got["clean"] is False
assert got["dirty"] is True
sent = got["sent"]
assert sent["base_revision"] == 3
assert sent["item"] == {
"name": "벌목",
"basis_quantity": 1,
"basis_unit": "ha",
"status": "reviewed",
}
assert [r.get("id") for r in sent["rows"]] == [1, None, 3]
# ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자
fields = {
"variant",
"resource_kind",
"resource_key",
"resource_name",
"resource_spec",
"amount",
"amount_unit",
"alternative_amount",
"group_ratio_pct",
"raw_cell",
"pum_table_id",
}
assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}]
assert sent["rows"][1]["variant"] == "" and sent["rows"][1]["pum_table_id"] == ""
# ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림
assert got["changed"] == ["amount"]
assert got["addedRow"] == "added"
assert got["untouched"] == []
assert got["freshUntouched"] == 0.5
# ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로
assert got["groups"] == [
{"variant": "", "indexes": [0, 3]},
{"variant": "", "indexes": [1, 2]},
]
-235
View File
@@ -1,235 +0,0 @@
"""Z01 공종 편집 — 규칙(순수 함수) · 요청 모델 · 저장 흐름(409·422·전부 아니면 전무) · 등록.
(2026-09-18 브레인 PLAN 1-0 계약 · sub_laptop_3) DB 없이 저장 흐름은 가짜 커넥션·저장소로 훑음.
"""
from __future__ import annotations
import importlib
from contextlib import asynccontextmanager
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from pydantic import ValidationError
from common_util.common_util_auth import require_system_admin, verify_session
from Z01_MasterData import Z01_MasterData_Router_WorkItems as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
SEED_ROW = {"resource_kind": "labor", "resource_key": "L-001", "amount": 1.5, "sort_order": 0}
def _row(row_id, seed=SEED_ROW, **values):
base = {"id": row_id, "variant": "", "resource_kind": "labor", "resource_key": "L-001"}
return {**base, "amount": Decimal("1.500000"), "sort_order": 0, "seed": seed, **values}
# ── 규칙 ──
def test_고친_칸은_초기값과_값으로_비교함() -> None:
assert work_items.changed_columns(_row(1), SEED_ROW, work_items.ROW_FIELDS) == []
changed = _row(1, amount=Decimal("2"))
assert work_items.changed_columns(changed, SEED_ROW, work_items.ROW_FIELDS) == ["amount"]
assert work_items.changed_columns(_row(1, seed=None), None, work_items.ROW_FIELDS) == []
def test_검사는_남의_줄_겹친_줄_없는_자원을_막음() -> None:
rows = [
{"id": 1, "resource_key": "L-001"},
{"id": 1, "resource_key": None},
{"id": 99, "resource_key": None},
{"id": None, "resource_key": "없는열쇠"},
]
errors = work_items.check_rows(rows, existing_ids={1, 2}, known_keys={"L-001"})
assert len(errors) == 3
assert "두 번" in errors[0] and "id 99" in errors[1] and "없는열쇠" in errors[2]
assert work_items.check_rows(rows[:1], {1}, {"L-001"}) == []
def test_전체_줄_보내기는_바뀐_줄만_쓰고_빠진_줄은_숨기거나_지움() -> None:
existing = [_row(1), _row(2, sort_order=1), _row(3, seed=None, sort_order=2)]
same = {f: existing[0].get(f) for f in work_items.ROW_FIELDS} | {"id": 1, "amount": 1.5}
new = {"id": None, "resource_kind": "material", "resource_key": None, "amount": None}
plan = work_items.plan_rows(existing, [same, new])
assert plan["updates"] == [] # 1.500000 = 1.5 · 순서도 그대로
assert plan["inserts"][0]["resource_kind"] == "material"
assert plan["inserts"][0]["sort_order"] == 1
assert plan["hide"] == [2] # 초기값 줄 — 숨김(되살릴 수 있게)
assert plan["drop"] == [3] # 관리자 추가 줄 — 지움
moved = work_items.plan_rows(existing[:2], [{**same, "id": 2}, same])
assert [u["id"] for u in moved["updates"]] == [2, 1] # 순서만 바뀌어도 sort_order 를 씀
def test_초기값으로는_seed_칸만_살리고_추가_줄은_지움() -> None:
head = {"id": 7, "name": "고친 이름", "seed": {"name": "원 이름", "status": "partial"}}
rows = [_row(1, amount=Decimal("9")), _row(2, is_deleted=1), _row(3, seed=None)]
plan = work_items.reset_plan(head, rows)
assert plan["head"] == {"name": "원 이름", "status": "partial"}
assert [u["id"] for u in plan["updates"]] == [1, 2]
assert plan["updates"][0]["amount"] == 1.5
assert plan["drop"] == [3] and plan["inserts"] == [] and plan["hide"] == []
def test_상세는_seed_를_안_내보내고_고친_칸만_알림() -> None:
head = {"id": 7, "work_item_key": "FW-00001", "name": "", "status": "partial"}
head |= {"seed": {"name": ""}, "source": [{"pum_table_id": "T1"}], "notes": ["[주] 1"]}
out = work_items.detail(head, [_row(1), _row(2, seed=None)], revision=4)
assert out["item"]["revision"] == 4 and out["item"]["changed_columns"] == ["name"]
assert "seed" not in out["item"] and "id" not in out["item"]
assert out["source"] == [{"pum_table_id": "T1"}] and out["notes"] == ["[주] 1"]
assert [r["is_added"] for r in out["rows"]] == [False, True]
assert out["rows"][0]["id"] == 1 and "seed" not in out["rows"][0]
# ── 요청 모델 ──
def _body(**row):
item = {"name": "공종", "status": "reviewed"}
return {"base_revision": 3, "item": item, "rows": [{"resource_kind": "labor", **row}]}
@pytest.mark.parametrize(
"row",
[{"amount": -1}, {"resource_key": ""}, {"resource_kind": "oil"}, {"unknown": 1}],
)
def test_요청_모델은_음수_빈열쇠_모르는_종류_모르는_칸을_막음(row) -> None:
with pytest.raises(ValidationError):
router_module.WorkItemSave.model_validate(_body(**row))
# ── 저장 흐름(가짜 커넥션) ──
class _Connection:
def __init__(self) -> None:
self.log: list[str] = []
async def begin(self) -> None:
self.log.append("begin")
async def commit(self) -> None:
self.log.append("commit")
async def rollback(self) -> None:
self.log.append("rollback")
@pytest.fixture
def client(monkeypatch):
connection = _Connection()
class _Pool:
@asynccontextmanager
async def acquire(self):
yield connection
head = {"id": 7, "work_item_key": "FW-00001", "name": "공종", "status": "partial"}
head |= {"seed": {"name": "공종"}, "source": [], "notes": []}
async def fetch_item(_c, key, lock=False):
return dict(head) if key == "FW-00001" else None
async def fetch_rows(_c, _item_id, with_deleted=False):
return [_row(1)]
async def revision(_c, _scope, lock=False):
return 3
async def known_resource_keys(_c, keys):
return keys & {"L-001"}
async def write(_c, *args):
connection.log.append("write")
async def bump_revision(_c, _scope):
connection.log.append("bump")
async def list_items(_c, **picked):
connection.log.append(picked["status"])
return {"items": [], "total": 2, "counts": {"partial": 2}}
monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool())
for name, fake in {
"list_items": list_items,
"fetch_item": fetch_item,
"fetch_rows": fetch_rows,
"revision": revision,
"known_resource_keys": known_resource_keys,
"write": write,
"bump_revision": bump_revision,
}.items():
monkeypatch.setattr(router_module.repo, name, fake)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42}
test_client = TestClient(app)
test_client.log = connection.log
return test_client
URL = "/api/master-data/work-items/FW-00001"
def test_낡은_revision_은_409_이고_아무것도_안_씀(client) -> None:
response = client.put(URL, json={**_body(id=1, resource_key="L-001"), "base_revision": 2})
assert response.status_code == 409
assert client.log == ["begin", "rollback"]
def test_없는_자원_열쇠는_422_이고_아무것도_안_씀(client) -> None:
response = client.put(URL, json=_body(resource_key="없는열쇠"))
assert response.status_code == 422 and "없는열쇠" in response.json()["detail"]
assert client.log == ["begin", "rollback"]
def test_맞는_저장은_쓰고_revision_올리고_새_상세를_줌(client) -> None:
response = client.put(URL, json=_body(id=1, resource_key="L-001", amount=2))
assert response.status_code == 200, response.text
assert client.log == ["begin", "write", "bump", "commit"]
assert response.json()["status"] == "success" and response.json()["item"]["revision"] == 3
def test_목록은_상태별_개수를_다_채우고_모르는_상태는_막음(client) -> None:
body = client.get("/api/master-data/work-items?status=").json()
assert body["counts"] == {s: 2 if s == "partial" else 0 for s in work_items.STATUSES}
assert client.get("/api/master-data/work-items?status=needs_input").status_code == 200
assert client.get("/api/master-data/work-items?status=bogus").status_code == 422
assert client.log == ["", "needs_input"]
def test_초기값으로와_없는_공종(client) -> None:
assert client.post(URL + "/reset").status_code == 200
assert client.log == ["begin", "write", "bump", "commit"]
assert client.get("/api/master-data/work-items/FW-99999").status_code == 404
# ── 등록 ──
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, m) for r in app.routes if isinstance(r, APIRoute) for m in r.methods}
wanted = {
("/api/master-data/work-items", "GET"),
("/api/master-data/work-items/{key}", "GET"),
("/api/master-data/work-items/{key}", "PUT"),
("/api/master-data/work-items/{key}/reset", "POST"),
}
assert wanted <= routes
for route in app.routes:
if isinstance(route, APIRoute) and route.path.startswith("/api/master-data/work-items"):
assert {verify_session, require_system_admin} <= _calls(route.dependant)
+6 -11
View File
@@ -12,17 +12,17 @@ from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import install
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(monkeypatch: pytest.MonkeyPatch) -> TestClient:
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
install(monkeypatch) # 기초단가 표 목록·줄 수가 DB 를 읽음 — 가짜로
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"}
@@ -131,15 +131,10 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const",
json={
"base_revision": 0,
"edits": [{"row_key": "CW-00002#01", "column": "name", "value": "바꿈"}],
},
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
json={"values": {"name": "바꿈"}},
)
assert (
res.status_code == 400 and "고칠 수 없는 표" in res.json()["message"]
) # 공종은 /work-items 몫
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
+47 -84
View File
@@ -142,105 +142,68 @@ export const ui_locales_b3 = {
Z01_MasterData_Title: ["마스터 데이터", "Master Data"],
Z01_MasterData_Tree: ["마스터 트리", "Master tree"],
/* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */
Z01_MasterData_List_Kind: ["표", "Table"],
Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"],
Z01_MasterData_Edit_Can: [
"고칠 수 있는 칸 — 눌러 고치고 Enter (Esc 취소) · [저장]을 눌러야 반영",
"Editable — click, type, Enter (Esc cancels) · applied on [Save]",
"고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)",
"Editable — click, type, Enter to save (Esc cancels)",
],
Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"],
Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"],
Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"],
Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"],
Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"],
Z01_MasterData_Edit_Invalid: [
"「{value}」 를 수로 못 읽음",
"Cannot read 「{value}」 as a number",
],
Z01_MasterData_Edit_NotSaved: ["⚠ 안 들어감 — {value}", "⚠ Not applied — {value}"],
Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"],
Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"],
Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"],
Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"],
Z01_MasterData_Formula: ["산출근거", "Basis"],
/* --- Z01 고치기 — [저장] 한 번에 보냄(자동저장 금지 · PLAN 1-0) --- */
Z01_MasterData_Save: ["저장", "Save"],
Z01_MasterData_Discard: ["고친 것 버리기", "Discard edits"],
Z01_MasterData_AddRow: ["줄 추가", "Add row"],
Z01_MasterData_DeleteRow: ["줄 삭제", "Delete row"],
Z01_MasterData_UndeleteRow: ["삭제 취소", "Undo delete"],
Z01_MasterData_Reset: ["초기값으로", "Reset to initial"],
Z01_MasterData_ResetConfirm: [
"고른 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)",
"Reset the picked row to its initial value — saved at once (admin-added rows are removed)",
Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"],
Z01_MasterData_State_SourceChanged: [
"⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}",
"⚠ Source changed after edit — edited value still used · new source {value}",
],
Z01_MasterData_ResetBlocked: [
"저장 안 한 고침이 있음 — 먼저 [저장]하거나 [고친 것 버리기]",
"Unsaved editssave or discard them first",
Z01_MasterData_State_MeaningChanged: [
"⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)",
"⛔ Editedthe row now means something else (same code, different item)",
],
Z01_MasterData_ResetDone: ["초기값으로 되돌렸음", "Reset to initial value"],
Z01_MasterData_Saved: ["저장했음", "Saved"],
Z01_MasterData_Unsaved: ["저장 안 한 고침이 있음", "There are unsaved edits"],
Z01_MasterData_Draft_Edits: ["고친 칸", "Edited cells"],
Z01_MasterData_Draft_Added: ["추가 줄", "Added rows"],
Z01_MasterData_Draft_Deleted: ["삭제 줄", "Deleted rows"],
Z01_MasterData_Draft_Pending: ["[저장]해야 반영됨", "applied on [Save]"],
Z01_MasterData_Draft_None: ["고친 것 없음", "No edits"],
Z01_MasterData_Draft_Stale: [
"⚠ 다른 곳에서 먼저 저장됨 — 이 초안은 저장 못 함 · [고친 것 버리기]로 새로 읽을 것",
"⚠ Saved elsewhere first — this draft cannot be saved · discard to reload",
Z01_MasterData_State_MeaningMissing: [
"⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그",
"⛔ Server sent no before/after row names — bug",
],
Z01_MasterData_Cell_Draft: ["저장 안 한 고침 — {value}", "Unsaved edit {value}"],
Z01_MasterData_Cell_Changed: ["초기값과 다름(저장된 고침)", "Differs from the initial value"],
Z01_MasterData_Row_Added: ["관리자가 추가한 줄", "Row added by an admin"],
Z01_MasterData_Row_New: ["새 줄 — 저장 전", "New row — not saved yet"],
/* --- Z01 공종 편집 (PLAN 1-4) --- */
Z01_WorkItem_Title: ["공종 편집", "Work items"],
Z01_WorkItem_Axis_Forest: ["산림 공종", "Forest"],
Z01_WorkItem_Axis_Const: ["건설 공종", "Construction"],
Z01_WorkItem_Pick: ["왼쪽 목록에서 공종을 고를 것", "Pick a work item from the list"],
Z01_WorkItem_Status: ["상태", "Status"],
Z01_WorkItem_Status_All: ["전부", "All"],
Z01_WorkItem_Status_NeedsInput: ["입력 필요", "Needs input"],
Z01_WorkItem_Status_Partial: ["일부 못 맞춤", "Partly unmatched"],
Z01_WorkItem_Status_AutoOk: ["자동 완료", "Auto OK"],
Z01_WorkItem_Status_NotItem: ["공종 아님", "Not a work item"],
Z01_WorkItem_Status_Reviewed: ["검수 완료", "Reviewed"],
Z01_WorkItem_Review: ["검수 완료", "Mark reviewed"],
Z01_WorkItem_Dirty: [
"저장 안 한 고침 있음 — [저장]해야 반영됨",
"Unsaved edits — applied on [Save]",
Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"],
Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"],
Z01_MasterData_Edit_DropOnMeaning: [
"고친 값 버리고 지금 줄 값 {value} 쓰기",
"Drop the edit and use the current row value {value}",
],
Z01_WorkItem_ResetConfirm: [
"이 공종의 머리·구성 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)",
"Reset this work item's header and rows — saved at once (admin-added rows are removed)",
Z01_MasterData_Edit_MeaningConfirm: [
"줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?",
"This row now means something else. Dropping the edit uses the current row's value. Continue?",
],
Z01_WorkItem_Key: ["열쇠", "Key"],
Z01_WorkItem_Number: ["번호", "Number"],
Z01_WorkItem_Parent: ["상위 공종", "Parent"],
Z01_WorkItem_Edition: ["판", "Edition"],
Z01_WorkItem_Name: ["이름", "Name"],
Z01_WorkItem_BasisQuantity: ["기준 수량", "Basis quantity"],
Z01_WorkItem_BasisUnit: ["기준 단위", "Basis unit"],
Z01_WorkItem_Rows: ["구성 줄", "Resource rows"],
Z01_WorkItem_NoVariant: ["갈래 없음", "No variant"],
Z01_WorkItem_VariantName: ["새 갈래 이름", "New variant name"],
Z01_WorkItem_AddVariant: ["갈래 추가", "Add variant"],
Z01_WorkItem_Col_Kind: ["종류", "Kind"],
Z01_WorkItem_Col_Resource: ["자원", "Resource"],
Z01_WorkItem_Col_Spec: ["규격", "Spec"],
Z01_WorkItem_Col_Key: ["기초단가 열쇠", "Base price key"],
Z01_WorkItem_Col_Amount: ["수량", "Amount"],
Z01_WorkItem_Col_Unit: ["단위", "Unit"],
Z01_WorkItem_Col_Alt: ["대체 수량", "Alternative"],
Z01_WorkItem_Col_Ratio: ["묶음 비율 %", "Group ratio %"],
Z01_WorkItem_Col_Raw: ["원문 글자", "Source text"],
Z01_WorkItem_Col_Table: ["품셈 표", "Table"],
Z01_WorkItem_Col_Delete: ["", ""],
Z01_WorkItem_Kind_Labor: ["노무", "Labor"],
Z01_WorkItem_Kind_Machine: ["기계", "Machine"],
Z01_WorkItem_Kind_Material: ["자재", "Material"],
Z01_WorkItem_Kind_Etc: ["기타", "Other"],
Z01_WorkItem_Unmatched: ["⚠ 못 맞춤", "⚠ Unmatched"],
Z01_WorkItem_Find: ["찾기", "Find"],
Z01_WorkItem_FindTitle: ["자원 찾기 — 기초단가에서 골라 이음", "Find resource in base prices"],
Z01_WorkItem_FindClose: ["닫기", "Close"],
Z01_WorkItem_FindNone: ["찾은 줄 없음", "Nothing found"],
Z01_WorkItem_Source: ["품셈 원문 (읽기 전용)", "Standard source (read-only)"],
Z01_WorkItem_NoSource: ["원문 표 없음", "No source table"],
Z01_WorkItem_Notes: ["[주]", "[Notes]"],
Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"],
Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"],
Z01_MasterData_State_Orphan: [
"⚠ 주인 없음 — 갱신으로 그 줄이 사라짐",
"⚠ Orphan — the row disappeared on update",
],
Z01_MasterData_State_Missing: [
"⚠ 서버가 판정(state)을 안 줌 — 버그",
"⚠ Server sent no state — bug",
],
Z01_MasterData_List_State: ["상태", "State"],
Z01_MasterData_List_Row: ["", "Row"],
Z01_MasterData_List_Column: ["", "Column"],
Z01_MasterData_List_Value: ["고친 값", "Edited value"],
Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"],
Z01_MasterData_List_Current: ["지금 원본", "Current source"],
Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"],
Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"],
Z01_MasterData_List_Edited: ["고쳐짐", "Edited"],
Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"],
Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"],
Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],