Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-09-19 01:54:36 +09:00
447 changed files with 8038 additions and 3995 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)
@@ -19,6 +19,7 @@ import difflib
import hashlib
import re
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
@@ -29,7 +30,8 @@ from resources.tester.test_const_pdf_coverage import _NUM, _page_rows # noqa: E
COST = ROOT / "resources/knowledge/original/원가계산"
#: 책마다 — PDF · 차례 쪽(PDF 쪽 번호) · 인쇄 1쪽이 놓인 PDF 쪽 · 머리 「문서」 칸 · 새 md 뿌리
#: 책마다 — PDF · 차례 쪽(PDF 쪽 번호) · 인쇄 1쪽이 놓인 PDF 쪽 · 머리 「문서」 칸 · 새 md 뿌리 ·
#: 차례 모양(block = 번호·이름·쪽이 줄마다 · inline = 한 줄에) · 절 머리 번호 뒤 마침표 · 쪽 꼬리(맨 아래 줄)
BOOKS = {
"산림": {
"pdf": COST / "산림_표준품셈/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
@@ -37,6 +39,21 @@ BOOKS = {
"first_body": 13,
"doc": "산림사업 표준품셈(산림청고시 제2025-82호)",
"out": COST / "산림_표준품셈/본문",
"toc_layout": "block",
"dot": True,
"footer": re.compile(r"^-\s*\d+\s*-$"), # 「- 123 -」
},
"건설": {
"pdf": COST / "건설공사_표준품셈/2026년_건설공사_표준품셈.pdf",
"toc": (3, 51),
"first_body": 57,
"doc": "2026 건설공사 표준품셈",
"out": COST / "건설공사_표준품셈/본문",
"toc_layout": "inline",
"dot": False, # 「1-5 기타」
"footer": re.compile(
r"^\d+\s+\S+부문$|^제\s*\d+\s*장.*\s\d+$"
), # 「30 공통부문」·「제1장 적용기준 31」
},
}
@@ -72,15 +89,15 @@ class Entry:
page: int # 인쇄 쪽
def toc_entries(doc, first: int, last: int) -> list[Entry]:
"""차례 쪽(PDF first~last)의 줄 — 번호 줄 · 이름 줄(점선) · 쪽 줄이 차례로 옴."""
lines = [
line
for number in range(first - 1, last)
for line in doc[number].get_text().splitlines()
if line.strip() not in _TOC_SKIP
def toc_entries(doc, book: dict) -> list[Entry]:
"""차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0)."""
first, last = book["toc"]
raw = [
line for number in range(first - 1, last) for line in doc[number].get_text().splitlines()
]
return parse_toc(lines)
if book["toc_layout"] == "inline":
return parse_toc_inline(raw)
return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP])
def parse_toc(raw: list[str]) -> list[Entry]:
@@ -102,6 +119,31 @@ def parse_toc(raw: list[str]) -> list[Entry]:
return out
_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$")
_DIVISION = re.compile(r"^\S+부문$")
def parse_toc_inline(raw: list[str]) -> list[Entry]:
"""건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄."""
lines = [line.strip() for line in raw if line.strip()]
out, i = [], 0
while i < len(lines):
line = lines[i]
if found := _INLINE.match(line):
out.append(Entry(found[1], found[2], int(found[3])))
elif re.fullmatch(r"\d+장", line):
out.append(Entry(line, lines[i + 1], int(lines[i + 2])))
i += 2
elif _DIVISION.match(line):
out.append(Entry(line, "", 0))
elif not (
line.replace(" ", "") == "목차" or line.isdigit()
): # 쪽 머리 「목차」 · 차례 쪽 번호
raise ValueError(f"차례 모양이 다름: {line!r}")
i += 1
return out
#: 가운뎃점 여러 벌 — 차례는 「보수․복구」(U+2024) · 본문은 「보수·복구」(U+00B7). 맞대기에서만 같게 봄.
_DOTS = str.maketrans({"": "·", "": "·", "": "·", "": "·"})
@@ -122,13 +164,13 @@ def locate(doc, marker: str, first_pdf: int, slack: int = 3) -> tuple[int, int]:
raise ValueError(f"표지를 못 찾음: {marker} (PDF {first_pdf}~{first_pdf + slack})")
def heading_pattern(ident: str) -> re.Pattern:
def heading_pattern(ident: str, dot: bool = True) -> re.Pattern:
"""본문 머리 줄 — **번호로** 찾음. 차례 이름에 오타가 있어서(「면벅」=면벽 · 「조림」=조립 …)."""
if ident.startswith(""):
return re.compile(r"^제\s*" + ident[1:-1] + r"\s*장(?!\d)")
if ident.startswith("부록"):
return re.compile(r"^\[?" + re.escape(ident) + r"\]?(?!\d)")
return re.compile(r"^" + re.escape(ident) + r"\.(?!\d)")
return re.compile(r"^\[?부록\s*" + ident[2:] + r"\]?(?!\d)") # 「[부록 1]」
return re.compile(r"^" + re.escape(ident) + (r"\." if dot else "") + r"(?![\d-])")
#: 글자 사이 빈칸으로 볼 틈(글자 크기 대비) — 본문 머리 실측: 빈칸 7.7pt · 붙은 글자 0.7pt(14pt 글꼴)
@@ -154,19 +196,40 @@ def visual_text(page, row: str) -> str:
return row
def find_heading(doc, ident: str, first_pdf: int, slack: int = 3) -> tuple[int, int, str]:
def find_heading(
doc, ident: str, first_pdf: int, slack: int = 3, dot: bool = True
) -> tuple[int, int, str]:
"""(PDF 쪽, 쪽 안 줄 차례, 머리 글자) — 머리 글자는 PDF 그대로(번호 뒤 마침표 포함) · 띄어쓰기는 쪽 그림대로."""
pattern = heading_pattern(ident)
pattern = heading_pattern(ident, dot)
for number in range(first_pdf, min(first_pdf + slack, doc.page_count) + 1):
for index, row in enumerate(_page_rows(doc[number - 1])):
if pattern.match(row.strip()):
return number, index, visual_text(doc[number - 1], row.strip())
title = visual_text(doc[number - 1], row.strip())
cut = pattern.match(title).end()
lead, name = title[:cut].strip(), title[cut:].strip()
return number, index, f"{lead} {join_spaced(name)}".strip()
raise ValueError(f"머리를 못 찾음: {ident} (PDF {first_pdf}~{first_pdf + slack})")
def name_of(ident: str, title: str) -> str:
def join_spaced(name: str) -> str:
"""자간을 벌려 찍은 이름은 붙임(「측 량」 → 「측량」 · 규칙 3장) — 이름 전체가 한 글자씩일 때만."""
parts = name.split(" ")
if len(parts) > 1 and all(len(p) == 1 and "" <= p <= "" for p in parts):
return "".join(parts)
return name
def name_of(ident: str, title: str, dot: bool = True) -> str:
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
return title[heading_pattern(ident).match(title).end() :].strip()
return title[heading_pattern(ident, dot).match(title).end() :].strip()
#: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김)
_REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$")
def file_label(ident: str, title: str, dot: bool = True) -> str:
return safe(_REVISION.sub("", name_of(ident, title, dot)))
def claimed_elsewhere(root: Path) -> set[str]:
@@ -231,87 +294,149 @@ def read_head(path: Path) -> dict[str, str]:
raise ValueError(f"머리 끝 없음: {path}")
_SLOT = re.compile(r"제(\d+)장_[^/]*/(\d+-\d+)_")
#: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리
_SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_")
#: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장)
_MAX_PAGES = 40
def _body_rows(page, footer: re.Pattern) -> list[str]:
"""쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌."""
rows = [row.strip() for row in _page_rows(page)]
if rows and footer.match(rows[-1]):
rows = rows[:-1]
return [row for row in rows if row != ""]
def _blank(page, footer: re.Pattern) -> bool:
return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS
def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""):
"""(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌."""
offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"]
start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot)
if following is None: # 문서 맨 끝
end, marker = doc.page_count, ""
else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목
nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot)
end = nxt - 1 if at == 0 else nxt
for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음
if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]):
end = page - 1
break
while end > start and _blank(doc[end - 1], footer):
end -= 1
return start, title, max(end, start), marker
def plan(book_name: str) -> list[Path]:
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기).
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다.
**이미 있는 자리는 건드림** 폴더에 있거나 다른 브랜치에 있는 ·(이름이 달라도 번호로 ).
건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 번호가 1부터.
"""
import pymupdf
book = BOOKS[book_name]
out = book["out"]
out, dot, footer = book["out"], book["dot"], book["footer"]
offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽
have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")}
taken = {f"{int(m[1])}/{m[2]}" for m in map(_SLOT.match, have) if m}
folders = {int(m[1]): rel.split("/")[0] for rel in have if (m := _SLOT.match(rel))}
taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m}
folders = {
(m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel))
}
written = []
def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None:
if f"{division}/{chapter_no}/{slot}" in taken:
return
# 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준
drawn = any(
_blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1)
)
path = out / division / folders[(division, chapter_no)] / file_name
path.parent.mkdir(parents=True, exist_ok=True)
values = {"문서": book["doc"], "": title, "시작표지": title, "끝표지": marker}
values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)}
values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"}
path.write_text(head_text(values), encoding="utf-8")
written.append(path)
with pymupdf.open(book["pdf"]) as doc:
entries = toc_entries(doc, *book["toc"])
# 경계가 되는 차례 줄 — 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌)
bounds = [e for e in entries if e.ident != "부록" and e.ident.count("-") < 2]
chapter_no = None
entries = toc_entries(doc, book)
# 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌)
at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2]
bounds = [entries[i] for i in at]
division, divisions, chapter_no = "", 0, None
for n, entry in enumerate(bounds):
if _DIVISION.match(entry.ident):
divisions += 1
division, chapter_no = f"{divisions:02d}_{entry.ident}", None
continue
if entry.ident.startswith("부록"):
continue
start_pdf, _, title = find_heading(doc, entry.ident, entry.page + offset)
following = bounds[n + 1] if n + 1 < len(bounds) else None
if following is None: # 문서 맨 끝 절
end_pdf, end_marker = doc.page_count, ""
else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목
next_pdf, at, end_marker = find_heading(
doc, following.ident, following.page + offset
)
end_pdf = max(next_pdf - 1 if at == 0 else next_pdf, start_pdf)
j = next(
(k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None
)
following = bounds[j] if j is not None else None
cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문
start, title, end, marker = _span_of(doc, book, entry, following, cover)
if entry.ident.startswith(""):
chapter_no = int(entry.ident[1:-1])
folders.setdefault(
chapter_no, f"{chapter_no:02d}장_{safe(name_of(entry.ident, title))}"
(division, chapter_no),
f"{chapter_no:02d}장_{file_label(entry.ident, title, dot)}",
)
write(
division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc
)
slot, file_name = "0-00", "0-00_장머리.md"
else:
major, minor = entry.ident.split("-")
slot = f"{major}-{int(minor):02d}"
file_name = f"{slot}_{safe(name_of(entry.ident, title))}.md"
if chapter_no is None or f"{chapter_no}/{slot}" in taken:
continue
empty = any(
len(re.sub(r"\s", "", doc[p - 1].get_text())) < _EMPTY_PAGE_CHARS
for p in range(start_pdf, end_pdf + 1)
)
path = out / folders[chapter_no] / file_name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
head_text(
{
"문서": book["doc"],
"": title,
"PDF쪽": _span(start_pdf, end_pdf),
"인쇄쪽": _span(start_pdf - offset, end_pdf - offset),
"시작표지": title,
"끝표지": end_marker,
"상태": "대기",
"원천": "그림" if empty else "글자층",
}
),
encoding="utf-8",
)
written.append(path)
if chapter_no is None:
continue
major, minor = entry.ident.split("-")
slot = f"{major}-{int(minor):02d}"
region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)]
items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident]
if end - start + 1 <= _MAX_PAGES or not items:
name = f"{slot}_{file_label(entry.ident, title, dot)}.md"
write(division, chapter_no, slot, name, start, title, end, marker, doc)
continue
# 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다
head_end = _span_of(doc, book, entry, items[0])
write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc)
for k, item in enumerate(items):
piece = _span_of(
doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover
)
if k + 1 == len(items): # 마지막 항은 절 끝까지
piece = (piece[0], piece[1], end, marker)
item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}"
name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md"
write(division, chapter_no, item_slot, name, *piece, doc)
return written
# ── check ──────────────────────────────────────────────────────────────
#: 쪽 번호 줄(「- 123 -」) — 절 글자에서 뺌
_PAGE_NO = re.compile(r"^-\s*\d+\s*-$")
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
_COMMENT = re.compile(r"<!--.*?-->", re.S)
_REPEATED = re.compile(r"<!--\s*되풀이 머리:(.*?)-->")
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …)
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])")
_HOLD = "\x00"
#: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선).
#: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는
#: `<!-- 글자층 밖 -->…<!-- /글자층 밖 -->` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄.
_PUA = re.compile("[\ue000-\uf8ff]")
_OFF_OPEN, _OFF_CLOSE = "\x01", "\x02"
_OFF_MARK = re.compile(r"<!--\s*(/?)글자층 밖\s*-->")
def _same_han(ch: str) -> str:
"""호환 한자(U+F900FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림."""
return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch
def book_of(path: Path) -> dict:
@@ -330,7 +455,12 @@ def split_head(text: str) -> tuple[str, str]:
def md_lines(body: str) -> list[tuple[int, str]]:
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터."""
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터.
`<!-- 되풀이 머리: -->` 쪽을 넘은 표가 PDF 에서 되풀이한 머리 (md 표에서는 · 규칙 4-5).
"""
body = _REPEATED.sub(r"\1", body)
body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body)
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
out = []
for number, line in enumerate(body.split("\n"), start=1):
@@ -348,8 +478,8 @@ def md_lines(body: str) -> list[tuple[int, str]]:
return out
def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 쪽 번호 줄 뺌."""
def pdf_lines(doc, head: dict[str, str], footer: re.Pattern) -> list[tuple[int, str]]:
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌."""
first, _, last = head["PDF쪽"].partition("~")
first, last = int(first), int(last or first)
start_page, start_row = locate(doc, head["시작표지"], first, slack=0)
@@ -360,21 +490,43 @@ def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
except ValueError:
pass
out = []
for number in range(start_page, min(end_page, doc.page_count) + 1):
for index, row in enumerate(_page_rows(doc[number - 1])):
for number in range(start_page, min(end_page, last) + 1):
rows = [row.strip() for row in _page_rows(doc[number - 1])]
if rows and footer.match(rows[-1]):
rows[-1] = "" # 쪽 꼬리
for index, row in enumerate(rows):
if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
continue
if not _PAGE_NO.match(row.strip()):
out.append((number, row))
if not row or row == "": # 이어짐 표지
continue
out.append((number, row))
return out
def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
"""(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄."""
kept, off, inside = [], [], False
for at, line in lines:
keep, gone = [], []
for ch in line:
if ch in (_OFF_OPEN, _OFF_CLOSE):
inside = ch == _OFF_OPEN
elif inside or _PUA.match(ch):
gone.append(ch)
else:
keep.append(ch)
kept.append((at, "".join(keep)))
if "".join(gone).strip():
off.append((at, "".join(gone)))
return kept, off
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄)."""
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로."""
text, where = [], []
for at, line in lines:
for ch in re.sub(r"\s", "", line):
text.append(ch)
text.append(_same_han(ch))
where.append(at)
return "".join(text), where
@@ -389,7 +541,9 @@ def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[st
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향."""
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈."""
pdf, pdf_off = _split_off(pdf)
md, md_off = _split_off(md)
pdf_n, pdf_at = _numbers(pdf)
md_n, md_at = _numbers(md)
pdf_t, pdf_where = _tight(pdf)
@@ -418,6 +572,11 @@ def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"char_missing": missing_c,
"char_extra": extra_c,
"spots": spots,
"off_pdf": (
sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off),
sorted({a for a, _ in pdf_off}),
),
"off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off),
}
@@ -429,9 +588,14 @@ def verdict(result: dict) -> str:
sum(result["char_extra"].values()),
)
word = "통과" if not any(counts) else "불통"
return (
line = (
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
)
glyphs, pages = result.get("off_pdf", (0, []))
if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게
where = f"({', '.join(map(str, pages))}쪽)" if pages else ""
line += f" · 글자층 밖 글리프 {glyphs}{where} · 받아씀 {result.get('off_md', 0)}"
return line
def fingerprint(text: str) -> str:
@@ -452,8 +616,9 @@ def check_file(path: Path) -> tuple[dict, str]:
import pymupdf
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
with pymupdf.open(book_of(path)["pdf"]) as doc:
result = compare(pdf_lines(doc, read_head(path)), md_lines(body))
book = book_of(path)
with pymupdf.open(book["pdf"]) as doc:
result = compare(pdf_lines(doc, read_head(path), book["footer"]), md_lines(body))
return result, verdict(result)
@@ -510,7 +675,7 @@ def status() -> str:
for name, book in BOOKS.items():
by_chapter: dict[str, collections.Counter] = {}
for path in sorted(book["out"].rglob("*.md")):
chapter = path.relative_to(book["out"]).parts[0]
chapter = path.parent.relative_to(book["out"]).as_posix()
by_chapter.setdefault(chapter, collections.Counter())[
read_head(path).get("상태", "")
] += 1
@@ -63,7 +63,7 @@ PDF쪽: 135~136
3. 머리가 두 단 이상이면 **윗머리를 md 머리 줄로, 아랫머리는 몸 첫 줄(들)로** 적고 주석을 담. 윗머리를 아랫머리마다 되풀이해 펼치지 않음.
`<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~4열 「소요인력(인/본당)」 -->`
4. 빈 칸은 빈 칸. `〃` · `-` · `·` 는 그대로 적음(위 값으로 채우지 않음).
5. 한 표가 쪽을 넘어가면 **한 표로 이음.** 넘어간 쪽에서 되풀이된 머리 줄은 뺌.
5. 한 표가 쪽을 넘어가면 **한 표로 이음.** 넘어간 쪽에서 되풀이된 머리 줄은 뺌. 뺀 자리(넘어간 첫 줄 앞)에 `<!-- 되풀이 머리: 명 칭 | 규 격 | 단위 -->` 로 그 글자를 적음 — 기계검사가 그 글자를 PDF 의 되풀이 머리와 맞대 셈.
6. 표가 너무 넓어 PDF 가 두 표로 나눠 찍었으면 PDF 대로 두 표.
## 5. 그림
@@ -80,6 +80,9 @@ PDF쪽: 135~136
| ② 글자 대조 | 도구 | PDF 글자(공백 뗌)가 md 에 빠짐없이 있고 md 에 없는 글자가 더해지지 않음 |
| ③ 자리 대조 | **작성하지 않은 다른 창** | 쪽 그림을 보며 표마다 수가 제 줄·제 칸에 있는지 · 병합 · 머리 · 밑수 · [주] |
- ①② 대조에서 빼는 것 — PDF 쪽: 쪽 맨 아래 꼬리(「- 51 -」 · 「30 공통부문」 · 「제1장 적용기준 31」) · 쪽 끝 이어짐 표지 「→」 / md 쪽: 머리 · `<!-- -->` · 그림 링크 · 표 문법(`|` · 구분 줄 · `<br>`) · 제목 `#` · 인용 `>` · 첨자 표시 `^` `_` (막음표 `\` 뒤 글자는 원문으로 셈).
- 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF · 식의 「(m³)」·분수선)는 ①② 에서 빼고 기계검사 칸에 「글자층 밖 글리프 N개(쪽)」로 적음. 그 자리를 그림 보고 받아쓴 md 글자는 `<!-- 글자층 밖 -->(m^3)<!-- /글자층 밖 -->` 로 감쌈(①② 에서 빠지고 「받아씀 N자」로 드러남) — **이 칸이 뜬 파일은 ③ 이 그림으로 그 식을 꼭 봄.** 사용자 영역 글리프 자리 말고는 감싸지 않음.
- 호환 한자(U+F900FAFF · 率 U+F9DB)는 ② 에서 통합 한자(率 U+7387)와 같게 봄. 다른 글자(㎥·①·㎡ 등)는 그대로 견줌.
- ①②가 0 이어도 칸이 뒤바뀐 것은 못 잡음 → ③ 없이는 `확정` 아님.
- ③에서 고칠 곳이 나오면 검증 창이 고치지 않고 **작성 창에 돌려보냄**(줄 번호 + PDF 쪽 + 무엇이 다른지).
- `확정` 뒤 파일이 바뀌면 시험이 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시.
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제1장 적용기준
PDF쪽: 59
인쇄쪽: 3
시작표지: 제1장 적용기준
끝표지: 1-1 일반사항
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 1-1 일반사항
PDF쪽: 59
인쇄쪽: 3
시작표지: 1-1 일반사항
끝표지: 1-2 설계 및 수량
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 1-2 설계 및 수량
PDF쪽: 59~75
인쇄쪽: 3~19
시작표지: 1-2 설계 및 수량
끝표지: 1-3 재료 및 노임의 할증
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 1-3 재료 및 노임의 할증
PDF쪽: 76~79
인쇄쪽: 20~23
시작표지: 1-3 재료 및 노임의 할증
끝표지: 1-4 품의 할증('97, '01, '03, '11, '14, '15, '16, '17년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 1-4 품의 할증('97, '01, '03, '11, '14, '15, '16, '17년 보완)
PDF쪽: 79~86
인쇄쪽: 23~30
시작표지: 1-4 품의 할증('97, '01, '03, '11, '14, '15, '16, '17년 보완)
끝표지: 1-5 기타
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 1-5 기타
PDF쪽: 87~89
인쇄쪽: 31~33
시작표지: 1-5 기타
끝표지: 제2장 가설공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제2장 가설공사
PDF쪽: 91
인쇄쪽: 35
시작표지: 제2장 가설공사
끝표지: 2-1 가설물의 한도
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-1 가설물의 한도
PDF쪽: 91~93
인쇄쪽: 35~37
시작표지: 2-1 가설물의 한도
끝표지: 2-2 손율
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-2 손율
PDF쪽: 94~96
인쇄쪽: 38~40
시작표지: 2-2 손율
끝표지: 2-3 가설건축물
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-3 가설건축물
PDF쪽: 97
인쇄쪽: 41
시작표지: 2-3 가설건축물
끝표지: 2-4 가설울타리 및 가설방음벽('09, '10, '17, '26년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-4 가설울타리 및 가설방음벽('09, '10, '17, '26년 보완)
PDF쪽: 97~99
인쇄쪽: 41~43
시작표지: 2-4 가설울타리 및 가설방음벽('09, '10, '17, '26년 보완)
끝표지: 2-5 규준틀
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-5 규준틀
PDF쪽: 99~100
인쇄쪽: 43~44
시작표지: 2-5 규준틀
끝표지: 2-6 동바리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-6 동바리
PDF쪽: 101~103
인쇄쪽: 45~47
시작표지: 2-6 동바리
끝표지: 2-7 비계
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-7 비계
PDF쪽: 103~106
인쇄쪽: 47~50
시작표지: 2-7 비계
끝표지: 2-8 추락재해 및 낙하물방지시설
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-8 추락재해 및 낙하물방지시설
PDF쪽: 106~112
인쇄쪽: 50~56
시작표지: 2-8 추락재해 및 낙하물방지시설
끝표지: 2-9 통행안전시설
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-9 통행안전시설
PDF쪽: 112~114
인쇄쪽: 56~58
시작표지: 2-9 통행안전시설
끝표지: 2-10 피해방지시설
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-10 피해방지시설
PDF쪽: 115~117
인쇄쪽: 59~61
시작표지: 2-10 피해방지시설
끝표지: 2-11 현장관리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-11 현장관리
PDF쪽: 117~119
인쇄쪽: 61~63
시작표지: 2-11 현장관리
끝표지: 2-12 공통장비
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 2-12 공통장비
PDF쪽: 120~122
인쇄쪽: 64~66
시작표지: 2-12 공통장비
끝표지: 제3장 토공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제3장 토공사
PDF쪽: 123
인쇄쪽: 67
시작표지: 제3장 토공사
끝표지: 3-1 공통사항
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-1 공통사항
PDF쪽: 123
인쇄쪽: 67
시작표지: 3-1 공통사항
끝표지: 3-2 굴착
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-2 굴착
PDF쪽: 123~126
인쇄쪽: 67~70
시작표지: 3-2 굴착
끝표지: 3-3 암발파 및 파쇄
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-3 암발파 및 파쇄
PDF쪽: 126~130
인쇄쪽: 70~74
시작표지: 3-3 암발파 및 파쇄
끝표지: 3-4 쌓기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-4 쌓기
PDF쪽: 130~133
인쇄쪽: 74~77
시작표지: 3-4 쌓기
끝표지: 3-5 절토부대공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-5 절토부대공
PDF쪽: 133~134
인쇄쪽: 77~78
시작표지: 3-5 절토부대공
끝표지: 3-6 성토부대공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-6 성토부대공
PDF쪽: 134
인쇄쪽: 78
시작표지: 3-6 성토부대공
끝표지: 3-7 비탈면 보호공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-7 비탈면 보호공
PDF쪽: 134~138
인쇄쪽: 78~82
시작표지: 3-7 비탈면 보호공
끝표지: 3-8 보강토 옹벽
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-8 보강토 옹벽
PDF쪽: 138~139
인쇄쪽: 82~83
시작표지: 3-8 보강토 옹벽
끝표지: 3-9 벌개제근
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-9 벌개제근
PDF쪽: 140
인쇄쪽: 84
시작표지: 3-9 벌개제근
끝표지: 3-10 개간
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-10 개간
PDF쪽: 141
인쇄쪽: 85
시작표지: 3-10 개간
끝표지: 3-11 스마트 토공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 3-11 스마트 토공
PDF쪽: 141~146
인쇄쪽: 85~90
시작표지: 3-11 스마트 토공
끝표지: 제4장 조경공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제4장 조경공사
PDF쪽: 147
인쇄쪽: 91
시작표지: 제4장 조경공사
끝표지: 4-1 잔디 및 초화류
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 4-1 잔디 및 초화류
PDF쪽: 147~148
인쇄쪽: 91~92
시작표지: 4-1 잔디 및 초화류
끝표지: 4-2 관목
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 4-2 관목
PDF쪽: 149~150
인쇄쪽: 93~94
시작표지: 4-2 관목
끝표지: 4-3 교목
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 4-3 교목
PDF쪽: 150~152
인쇄쪽: 94~96
시작표지: 4-3 교목
끝표지: 4-4 조경시설물
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 4-4 조경시설물
PDF쪽: 153~154
인쇄쪽: 97~98
시작표지: 4-4 조경시설물
끝표지: 제5장 기초공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제5장 기초공사
PDF쪽: 155
인쇄쪽: 99
시작표지: 제5장 기초공사
끝표지: 5-1 흙막이 및 물막이
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 5-1 흙막이 및 물막이
PDF쪽: 155~165
인쇄쪽: 99~109
시작표지: 5-1 흙막이 및 물막이
끝표지: 5-2 연약지반처리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 5-2 연약지반처리
PDF쪽: 165~172
인쇄쪽: 109~116
시작표지: 5-2 연약지반처리
끝표지: 5-3 말뚝
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 5-3 말뚝
PDF쪽: 172~183
인쇄쪽: 116~127
시작표지: 5-3 말뚝
끝표지: 5-4 차수
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 5-4 차수
PDF쪽: 183~184
인쇄쪽: 127~128
시작표지: 5-4 차수
끝표지: 제6장 철근콘크리트공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제6장 철근콘크리트공사
PDF쪽: 185
인쇄쪽: 129
시작표지: 제6장 철근콘크리트공사
끝표지: 6-1 콘크리트('25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-1 콘크리트('25년 보완)
PDF쪽: 185~188
인쇄쪽: 129~132
시작표지: 6-1 콘크리트('25년 보완)
끝표지: 6-2 철근
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-2 철근
PDF쪽: 189~191
인쇄쪽: 133~135
시작표지: 6-2 철근
끝표지: 6-3 거푸집
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-3 거푸집
PDF쪽: 191~199
인쇄쪽: 135~143
시작표지: 6-3 거푸집
끝표지: 6-4 포스트텐션(Post Tension) 구조물 제작
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-4 포스트텐션(Post Tension) 구조물 제작
PDF쪽: 200~205
인쇄쪽: 144~149
시작표지: 6-4 포스트텐션(Post Tension) 구조물 제작
끝표지: 6-5 교량 가설공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-5 교량 가설공
PDF쪽: 205~206
인쇄쪽: 149~150
시작표지: 6-5 교량 가설공
끝표지: 6-6 교량 부대공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-6 교량 부대공
PDF쪽: 206~211
인쇄쪽: 150~155
시작표지: 6-6 교량 부대공
끝표지: 6-7 조립식 구조물 설치공
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 6-7 조립식 구조물 설치공
PDF쪽: 211~214
인쇄쪽: 155~158
시작표지: 6-7 조립식 구조물 설치공
끝표지: 제7장 돌공사
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제7장 돌공사
PDF쪽: 215
인쇄쪽: 159
시작표지: 제7장 돌공사
끝표지: 7-1 돌쌓기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 7-1 돌쌓기
PDF쪽: 215
인쇄쪽: 159
시작표지: 7-1 돌쌓기
끝표지: 7-2 돌붙임
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 7-2 돌붙임
PDF쪽: 216
인쇄쪽: 160
시작표지: 7-2 돌붙임
끝표지: 7-3 전석쌓기 및 깔기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 7-3 전석쌓기 및 깔기
PDF쪽: 216~217
인쇄쪽: 160~161
시작표지: 7-3 전석쌓기 및 깔기
끝표지: 7-4 석재판 붙임
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 7-4 석재판 붙임
PDF쪽: 217~218
인쇄쪽: 161~162
시작표지: 7-4 석재판 붙임
끝표지: 제8장 건설기계
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 제8장 건설기계
PDF쪽: 219
인쇄쪽: 163
시작표지: 제8장 건설기계
끝표지: 8-1 적용기준
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-1 적용기준
PDF쪽: 219~224
인쇄쪽: 163~168
시작표지: 8-1 적용기준
끝표지: 8-2 시공능력
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2 시공능력
PDF쪽: 224
인쇄쪽: 168
시작표지: 8-2 시공능력
끝표지: 8-2-1 불도저('25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-1 불도저('25년 보완)
PDF쪽: 224~226
인쇄쪽: 168~170
시작표지: 8-2-1 불도저('25년 보완)
끝표지: 8-2-2 리퍼(유압식)('25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-2 리퍼(유압식)('25년 보완)
PDF쪽: 226~227
인쇄쪽: 170~171
시작표지: 8-2-2 리퍼(유압식)('25년 보완)
끝표지: 8-2-3 굴착기('04, '07, '09, '25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-3 굴착기('04, '07, '09, '25년 보완)
PDF쪽: 227~229
인쇄쪽: 171~173
시작표지: 8-2-3 굴착기('04, '07, '09, '25년 보완)
끝표지: 8-2-4 트랜처
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-4 트랜처
PDF쪽: 229~230
인쇄쪽: 173~174
시작표지: 8-2-4 트랜처
끝표지: 8-2-5 로더('07, '20년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-5 로더('07, '20년 보완)
PDF쪽: 230~232
인쇄쪽: 174~176
시작표지: 8-2-5 로더('07, '20년 보완)
끝표지: 8-2-6 모터 스크레이퍼
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-6 모터 스크레이퍼
PDF쪽: 232~233
인쇄쪽: 176~177
시작표지: 8-2-6 모터 스크레이퍼
끝표지: 8-2-7 모터 그레이더('25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-7 모터 그레이더('25년 보완)
PDF쪽: 234~235
인쇄쪽: 178~179
시작표지: 8-2-7 모터 그레이더('25년 보완)
끝표지: 8-2-8 덤프트럭('17년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-8 덤프트럭('17년 보완)
PDF쪽: 235~237
인쇄쪽: 179~181
시작표지: 8-2-8 덤프트럭('17년 보완)
끝표지: 8-2-9 롤러('04, '17, '25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-9 롤러('04, '17, '25년 보완)
PDF쪽: 238~240
인쇄쪽: 182~184
시작표지: 8-2-9 롤러('04, '17, '25년 보완)
끝표지: 8-2-10 아스팔트 플랜트
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-10 아스팔트 플랜트
PDF쪽: 241
인쇄쪽: 185
시작표지: 8-2-10 아스팔트 플랜트
끝표지: 8-2-11 스테이빌라이저(노상안정기)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-11 스테이빌라이저(노상안정기)
PDF쪽: 241~242
인쇄쪽: 185~186
시작표지: 8-2-11 스테이빌라이저(노상안정기)
끝표지: 8-2-12 크러셔
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-12 크러셔
PDF쪽: 242~248
인쇄쪽: 186~192
시작표지: 8-2-12 크러셔
끝표지: 8-2-13 대형브레이커('14, '17, '25년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-13 대형브레이커('14, '17, '25년 보완)
PDF쪽: 248~249
인쇄쪽: 192~193
시작표지: 8-2-13 대형브레이커('14, '17, '25년 보완)
끝표지: 8-2-14 압쇄기(콘크리트 소할용)('04년 신설)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-14 압쇄기(콘크리트 소할용)('04년 신설)
PDF쪽: 249
인쇄쪽: 193
시작표지: 8-2-14 압쇄기(콘크리트 소할용)('04년 신설)
끝표지: 8-2-15 법면다짐기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-15 법면다짐기
PDF쪽: 250
인쇄쪽: 194
시작표지: 8-2-15 법면다짐기
끝표지: 8-2-16 골재세척설비('01년 신설)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-16 골재세척설비('01년 신설)
PDF쪽: 250
인쇄쪽: 194
시작표지: 8-2-16 골재세척설비('01년 신설)
끝표지: 8-2-17 콘크리트 믹서
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-17 콘크리트 믹서
PDF쪽: 250
인쇄쪽: 194
시작표지: 8-2-17 콘크리트 믹서
끝표지: 8-2-18 콘크리트 배치플랜트(강제 혼합식)('00, '02, '11년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-18 콘크리트 배치플랜트(강제 혼합식)('00, '02, '11년 보완)
PDF쪽: 250~251
인쇄쪽: 194~195
시작표지: 8-2-18 콘크리트 배치플랜트(강제 혼합식)('00, '02, '11년 보완)
끝표지: 8-2-19 콘크리트 운반
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-19 콘크리트 운반
PDF쪽: 251~252
인쇄쪽: 195~196
시작표지: 8-2-19 콘크리트 운반
끝표지: 8-2-20 기관차
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-20 기관차
PDF쪽: 252~253
인쇄쪽: 196~197
시작표지: 8-2-20 기관차
끝표지: 8-2-21 경운기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-21 경운기
PDF쪽: 253
인쇄쪽: 197
시작표지: 8-2-21 경운기
끝표지: 8-2-22  디젤 파일 해머
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-22  디젤 파일 해머
PDF쪽: 254~258
인쇄쪽: 198~202
시작표지: 8-2-22  디젤 파일 해머
끝표지: 8-2-23 유압 파일 해머
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 2026 건설공사 표준품셈
절: 8-2-23 유압 파일 해머
PDF쪽: 258~262
인쇄쪽: 202~206
시작표지: 8-2-23 유압 파일 해머
끝표지: 8-2-24 진동파일 해머('96, '26년 보완)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---

Some files were not shown because too many files have changed in this diff Show More