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

This commit is contained in:
2026-09-19 01:32:50 +09:00
223 changed files with 7493 additions and 1011 deletions
+159 -37
View File
@@ -2,17 +2,12 @@
* Z01_MasterData_Api_Fetch.ts
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
*
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
* 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄
* (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type {
MasterColumn,
OverrideState,
ServedKind,
SortOrder,
TableMeta,
} from "./Z01_MasterData_UI_Cells";
import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells";
export interface MasterTable {
id: string;
@@ -38,11 +33,16 @@ export interface MasterGroup {
files: MasterFile[];
}
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
/**
* 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴.
* 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음.
*/
export interface MasterRows extends TableMeta {
columns: MasterColumn[];
rows: Record<string, unknown>[];
total: number;
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
revision?: number;
}
export interface RowsQuery {
@@ -65,17 +65,34 @@ 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()) as { detail?: string } & T;
if (!response.ok) throw new Error(data.detail ?? "Request failed");
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);
}
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),
@@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
}
export interface OverrideItem {
kind: BasePriceKind;
row_id: string;
row_label: string;
column: string;
column_label: string;
original: unknown;
current?: unknown;
value: unknown;
unit?: string;
state: OverrideState;
/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */
export interface BaseSave {
base_revision: number;
edits: { row_key: string; column: string; value: unknown }[];
added: { data: Record<string, unknown> }[];
deleted: string[];
}
/** 고친 것 모아 보기 — 서버가 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}`);
export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise<unknown> {
return send("PUT", `/master-data/base-prices/${kind}`, body);
}
/** 칸 고치기 — 값 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 }),
/** 고른 줄을 초기값(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),
});
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[]> {
+158 -111
View File
@@ -1,16 +1,20 @@
"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약).
표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만)
줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄)
줄 수준: `@id`(자료 제 열쇠) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자 추가 줄) · `@formula`(계산값 줄)
`@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code ·
자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]`(원본 값 그대로 · 열 이름은 이름표 것) · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌)
파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`).
= DB `master_base_price`(2026-09-18 PLAN 1-0 · `Z01_MasterData_Repository_BasePrices`) — 파일 원본(`base_rows`)
초기값 1회 주입(`Z01_MasterData_Seed`)의 재료 · 공종 축 둘만 아직 파일 읽기.
"""
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
@@ -19,7 +23,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_Overrides as overrides
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
@@ -28,7 +32,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",
@@ -39,14 +43,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:
@@ -277,7 +281,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))
@@ -290,33 +294,67 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
return _BUILDERS[kind]()
def _machine_inputs() -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일."""
labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"]
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")]
oil = [
r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}"
r
for r in oil
if r["@id"] == f"national_average/{r.get('fuel')}" and r.get("price_krw_per_l") is not None
]
return machine.Inputs(
wages={
str(r["occupation_code"]): None
if r["daily_wage_krw"] is None
if r.get("daily_wage_krw") is None
else Decimal(str(r["daily_wage_krw"]))
for r in labor
},
wage_date=labor[0]["effective_date"] if labor else None,
wage_date=labor[0].get("effective_date") if labor else None,
oil={r["fuel"]: Decimal(str(r["price_krw_per_l"])) for r in oil},
oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil},
oil_dates={r["fuel"]: r.get("date") or r.get("effective_date") for r in oil},
operating_date=edition_of(_doc("pum_const")[0]),
)
def rows(kind: str) -> list[dict[str, Any]]:
"""덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈."""
out = overrides.overlay(kind, base_rows(kind))
if kind == "machine":
inputs = _machine_inputs()
out = [machine.compute(r, inputs) for r in out]
return out
def 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 _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]:
@@ -446,45 +484,26 @@ def group_label(group: str) -> str:
return named.get("name_ko") or group
def kinds() -> list[dict[str, Any]]:
async def kinds() -> list[dict[str, Any]]:
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수(DB · 공종 축은 파일).
"""
counted = await repo.counts()
return [
{
"group": kind_group(kind),
"group_label": group_label(kind_group(kind)),
"kind": kind,
"label": kind_label(kind),
"rows": len(rows(kind)),
"rows": len(work_items.base_rows(kind))
if kind in work_items.KINDS
else counted.get(kind, 0),
}
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()
@@ -501,27 +520,12 @@ def column_meta(kind: str, key: str) -> dict[str, Any]:
}
#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨
_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
_INNER = ("@source", "@axis", "@data", "@added", "@changed")
def public(row: dict[str, Any]) -> dict[str, Any]:
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌."""
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`·`@data`…)은 뺌."""
return {k: v for k, v in row.items() if k not in _INNER}
#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」).
@@ -538,6 +542,7 @@ 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 = "",
@@ -545,7 +550,7 @@ def table(
desc: bool = False,
filters: dict[str, str] | None = None,
) -> dict[str, Any]:
all_rows = rows(kind)
"""한 표 한 쪽 — `all_rows` 는 `rows(kind)` 가 준 전체(DB 읽기와 셈을 떼어 둠)."""
columns = _columns(kind, all_rows)
needle = fold(q)
hits = (
@@ -569,7 +574,14 @@ def table(
"columns": [column_meta(kind, c) for c in columns],
# 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림
"sortable": list(columns),
"rows": [public(r) for r in hits[start : start + size]],
"rows": [
{
**public(r),
"changed_columns": r.get("@changed", []),
"is_added": r.get("@added", False),
}
for r in hits[start : start + size]
],
"total": len(hits),
**spec(kind, columns),
"notice": notice(kind),
@@ -578,49 +590,84 @@ def table(
}
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)
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)
if problem:
return 400, {"message": f"{column}: {problem}"}
overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"]))
return 200, public(next(r for r in rows(kind) if r["@id"] == row_id))
def override_items(
kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE
) -> dict[str, Any]:
"""모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수."""
out = []
counts: dict[str, dict[str, int]] = {}
for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만
base = base_rows(each)
by_id = {r["@id"]: r for r in base}
for item in overrides.items(each, base):
counts.setdefault(each, {}).setdefault(item["state"], 0)
counts[each][item["state"]] += 1
if (kind and each != kind) or (state and item["state"] != state):
continue
item["row_label"] = row_label(each, by_id.get(item["row_id"]))
meta = column_meta(each, item["column"])
item["column_label"] = meta["label"]
item["unit"] = meta["unit"]
out.append(item)
out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"]))
size = max(1, min(size, tables.MAX_PAGE_SIZE))
start = (max(page, 1) - 1) * size
return {"items": out[start : start + size], "total": len(out), "counts": counts}
def clear_overrides(kind: str, state: str) -> int:
return overrides.clear(kind, state, base_rows(kind))
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))
@@ -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,13 +9,14 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from dataclasses import dataclass
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,
)
@@ -40,7 +41,7 @@ _FORMULA = {
_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜"
_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님"
_LOCKED = {
"machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음",
"machine_code": "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음",
"machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"specification": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
@@ -50,8 +51,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": "운전경비 원단위가 나온 건설품셈 판 기준일",
@@ -75,7 +76,7 @@ def _fmt(value: Decimal | int | float) -> str:
@dataclass(frozen=True)
class Inputs:
"""기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께."""
"""기계 셈이 다른 표에서 끌어 오는 값 — DB 지금 값 · 판 기준일과 함께."""
wages: dict[str, Decimal | None]
wage_date: str | None
@@ -123,10 +124,13 @@ 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]:
"""덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길."""
catalog, records = _inputs()
"""DB 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.
기종 제원은 줄 값에서(카탈로그 아님) — 관리자가 추가한 줄도 섬 · 운전경비 원단위는 코드로 찾아 없으면 빈칸.
"""
_, records = _inputs()
code = row["@id"]
coef = row["loss_coefficient_per_hour"]
coef = row.get("loss_coefficient_per_hour")
record = records.get(code)
out = {
**row,
@@ -137,13 +141,17 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
"operator_wage_date": None,
**dict.fromkeys(COMPUTED),
}
if coef is None:
out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬"
if coef is None or row.get("price_thousand_krw") is None:
out["hourly_note"] = f"{'손료계수' if coef is None else '취득가'} 없음 — 시간당 단가 안 섬"
return out
spec = replace(
catalog.machines[code],
spec = MachineSpec(
machine_code=code,
name=str(row.get("machine_name") or ""),
specification=str(row.get("specification") or ""),
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
@@ -1,202 +0,0 @@
"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」).
원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움
덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일
읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}`
state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음):
edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본)
source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함)
orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김
되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌.
"""
from __future__ import annotations
import json
import math
import threading
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override"
EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan"
#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격).
#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16).
MEANING_CHANGED = "meaning_changed"
STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위
_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요
def _path(kind: str) -> Path:
return OVERRIDE_DIR / f"{kind}.json"
def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]:
"""`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개."""
path = _path(kind)
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {}
def same(a: Any, b: Any) -> bool:
"""값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음."""
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return Decimal(str(a)) == Decimal(str(b))
return a == b
def check_value(value: Any) -> str | None:
"""넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "숫자만 넣을 수 있음"
if not math.isfinite(value) or value < 0:
return "0 이상 유한한 수만 넣을 수 있음"
return None
#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌.
_JOIN = " · "
def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]:
"""(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘."""
stored = entry.get("meaning") or {}
keys = [k for k in stored if row is not None and k in row]
if not keys or all(row[k] == stored[k] for k in keys):
return "", ""
return (
_JOIN.join(str(stored[k]) for k in keys),
_JOIN.join(str(row[k]) for k in keys),
)
def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]:
original, value = entry.get("original"), entry.get("value")
was, now = meaning_line(entry, row)
if was:
state = MEANING_CHANGED
else:
state = EDITED if same(original, current) else SOURCE_CHANGED
out = {"original": original, "value": value, "state": state, "current": current}
if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감
out["was"], out["now"] = was, now
return out
def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**."""
store = load(kind)
out = []
for row in rows:
entries = store.get(row["@id"])
if not entries:
out.append(row)
continue
row = dict(row)
marks = {}
for column, entry in entries.items():
if column not in row:
continue # 주인 없는 칸 — 모아 보기에만
marks[column] = mark(entry, row[column], row)
row[column] = entry.get("value")
if marks:
row["@overrides"] = marks
out.append(row)
return out
def write(
kind: str,
base_row: dict[str, Any],
values: dict[str, Any],
by: Any,
meaning: dict[str, Any] | None = None,
) -> None:
"""고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림."""
row_id = base_row["@id"]
with _LOCK:
store = load(kind)
entries = dict(store.get(row_id) or {})
for column, value in values.items():
current = base_row.get(column)
if value is None or same(value, current):
entries.pop(column, None)
continue
entries[column] = {
"value": value,
"original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed
"source": base_row.get("@source", ""),
# 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음
"meaning": meaning or {},
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
"by": by,
}
if entries:
store[row_id] = entries
else:
store.pop(row_id, None)
_save(kind, store)
def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None:
atomic_write_json(
_path(kind),
{
"schema_version": "1.0",
"kind": kind,
"note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀",
"overrides": store,
},
)
def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int:
"""한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수."""
with _LOCK:
doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state}
if not doomed:
return 0
store = load(kind)
for row_id, column in doomed:
store[row_id].pop(column, None)
if not store[row_id]:
del store[row_id]
_save(kind, store)
return len(doomed)
def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로."""
by_id = {r["@id"]: r for r in base_rows}
out = []
for row_id, entries in load(kind).items():
row = by_id.get(row_id)
for column, entry in entries.items():
if row is None or column not in row:
current, marked = None, {"state": ORPHAN}
else:
current = row[column]
marked = mark(entry, current, row)
out.append(
{
"kind": kind,
"row_id": row_id,
"column": column,
"original": entry.get("original"),
"current": current,
"value": entry.get("value"),
"state": marked["state"],
**({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}),
}
)
return out
@@ -0,0 +1,168 @@
"""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
@@ -0,0 +1,181 @@
"""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],
)
+113 -39
View File
@@ -1,32 +1,69 @@
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인).
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약).
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 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
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} → 초기값으로(추가 줄은 지움)
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장).
"""
from __future__ import annotations
from typing import Any, Literal
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
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 BasePriceEdit(BaseModel):
values: dict[str, Any]
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})
@router.get("/tree")
@@ -50,14 +87,19 @@ 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")
def get_base_price_kinds() -> dict:
async def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"kinds": base_prices.kinds()}
return {"status": "success", "kinds": await base_prices.kinds()}
@router.get("/base-prices/{kind}")
def get_base_prices(
async def get_base_prices(
kind: str,
request: Request,
page: int = 1,
@@ -66,39 +108,71 @@ 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:
return base_prices.table(
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
table = await run_in_threadpool(
base_prices.table,
kind,
all_rows,
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}/{row_id:path}")
def put_base_price(
kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session)
@router.put("/base-prices/{kind}")
async def put_base_prices(
kind: str, body: BasePriceSave, session: dict = Depends(verify_session)
) -> JSONResponse:
status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id"))
if status != 200:
raise HTTPException(status_code=status, detail=payload["message"])
return JSONResponse(content=payload)
"""고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀)."""
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)})
@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)}
@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})
@@ -0,0 +1,152 @@
"""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
@@ -0,0 +1,263 @@
/* =============================================================================
* 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;
}
+25 -4
View File
@@ -15,8 +15,9 @@ import { navigateTo } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch";
import type { ServedKind } from "./Z01_MasterData_UI_Cells";
import { buildRowsView } from "./Z01_MasterData_UI_Rows";
import { buildRowsView, type RowsView } 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> {
@@ -41,12 +42,32 @@ export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
}
function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement {
const view = buildRowsView();
// 오른쪽은 표 보기 · 공종 편집 둘 중 하나 — 왼쪽에서 누른 쪽만 보임.
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 layout = el("div", { className: "ui-workflow-layout z01-master" });
const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [rows.root, items.root],
});
const overlays = createWorkflowOverlays({
title: L("Z01_MasterData_Title"),
optionsContent: buildSidePanel(groups, kinds, view),
optionsContent: buildSidePanel(groups, kinds, view, (axis) => {
swap(false);
items.show(axis);
}),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
+286 -153
View File
@@ -1,10 +1,11 @@
/* =============================================================================
* Z01_MasterData_UI_Rows.ts
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 고침
*
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 있어 editable 칸을 고침.
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `edit` 있어 고침.
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 산출근거가 섬
* (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것).
* ⚠ 자동저장 금지 — 고친 칸·추가 줄·삭제 줄은 초안(`BaseDraft`)에 쌓고 [저장] 한 번에 보냄(PLAN 1-0).
* ========================================================================== */
import {
@@ -12,14 +13,22 @@ import {
createInputField,
createSelectField,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
import {
ApiError,
type BaseSave,
type MasterRows,
type RowsQuery,
} from "./Z01_MasterData_Api_Fetch";
import {
boldSegments,
cellInfo,
cellText,
clampPage,
columnTitle,
filterValue,
@@ -32,24 +41,29 @@ 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>;
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
save?: (rowId: string, values: Row) => Promise<Row>;
/** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */
edit?: {
scope: string;
save: (body: BaseSave) => Promise<unknown>;
reset: (rowKeys: string[]) => Promise<unknown>;
};
}
export interface RowsView {
@@ -58,36 +72,15 @@ export interface RowsView {
}
/** 사전 문구의 {value} 자리에 값을 넣음. */
function fill(key: Parameters<typeof L>[0], value: string): string {
function fill(key: LocaleKey, value: string): string {
return L(key).replace("{value}", value);
}
/**
* 뜻이 바뀐 줄의 옛 이름·지금 이름 — 서버가 합쳐 한 줄로 줌(자재는 품명·규격·인도조건이 이어 붙어 옴).
* 화면은 쪼개거나 다시 합치지 않음. 안 오면 버그로 드러냄.
*/
function meaningNames(info: CellInfo): string {
if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing");
return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`;
}
/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */
function stateText(info: CellInfo, column: MasterColumn): string {
const original = valueWithUnit(info.original, column.unit);
switch (info.state) {
case "meaning_changed":
// 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히.
// ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김.
return `${L("Z01_MasterData_State_MeaningChanged")}${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`;
case "source_changed":
return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`;
case "orphan":
return L("Z01_MasterData_State_Orphan");
case "missing":
return L("Z01_MasterData_State_Missing");
default:
return fill("Z01_MasterData_State_Edited", original);
}
/** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */
interface Picked {
row: string;
added: boolean;
column: string;
}
export function buildRowsView(): RowsView {
@@ -100,7 +93,10 @@ export function buildRowsView(): RowsView {
};
let showHidden = false;
let last: MasterRows | null = null;
let selected: { index: number; key: string } | null = null;
let picked: Picked | null = null;
let draft: BaseDraft | null = null;
/** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */
let stale = false;
let searchTimer = 0;
const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query));
@@ -153,12 +149,26 @@ 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,
@@ -170,6 +180,13 @@ 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);
@@ -204,15 +221,65 @@ export function buildRowsView(): RowsView {
if (!data) return;
grid.classList.remove("is-loading");
last = data;
selected = null;
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;
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?.save && meta.editable?.includes(column.key)) {
if (source?.edit && meta.editable?.includes(column.key)) {
marks.push(["✎", L("Z01_MasterData_Edit_Can")]);
}
const reason = meta.locked?.[column.key];
@@ -286,39 +353,11 @@ 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");
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);
});
// 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐.
for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added));
meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index)));
grid.replaceChildren(
meta.rows.length
body.children.length
? el("table", {
className: "z01-master__grid",
children: [el("thead", { children: [headRow] }), body],
@@ -331,25 +370,100 @@ 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 selectCell(index: number, column: MasterColumn, failure?: string): void {
if (!last) return;
selected = { index, key: column.key };
const row = last.rows[index];
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))] ?? {});
const info = cellInfo(last, row, column.key);
const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })];
const line = (text: string) => el("span", { className: "z01-master__detail-line", text });
if (failure) {
const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure));
failed.classList.add("is-warning");
lines.push(failed);
}
if (source?.save) {
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) {
lines.push(
line(
info.editable
@@ -357,30 +471,14 @@ export function buildRowsView(): RowsView {
: `${L("Z01_MasterData_Edit_Locked")}${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`,
),
);
}
if (info.state) {
const stateLine = line(stateText(info, column));
if (info.state !== "edited") stateLine.classList.add("is-warning");
lines.push(stateLine);
// 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인).
// 뜻이 바뀐 줄에서 「새 원본 값으로 받기」는 위험 — 글자를 가르고 한 번 더 물음(브레인 ③).
const target =
info.state === "meaning_changed"
? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit))
: info.state === "source_changed"
? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit))
: info.state === "orphan"
? null
: fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit));
if (target && source?.save) {
lines.push(
createButton({
label: target,
variant: info.state === "meaning_changed" ? "danger" : "ghost",
onClick: () => void drop(index, column, info),
}),
);
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 (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));
@@ -388,31 +486,38 @@ export function buildRowsView(): RowsView {
detail.hidden = false;
}
function startEdit(td: HTMLElement, index: number, column: MasterColumn): void {
if (!last) return;
const current = last.rows[index][column.key];
/**
* 칸 고치기 — Enter·칸 나감 = 초안에 넣음(서버엔 안 감) · Esc = 그대로.
* `typeOf` = 수로 읽을지 가르는 본보기 값 · `allowEmpty` = 비우면 칸을 뺌(새 줄).
*/
function startEdit(
td: HTMLElement,
current: unknown,
typeOf: unknown,
apply: (value: unknown) => void,
allowEmpty = false,
): void {
const input = el("input", { className: "ui-input z01-master__cell-input" });
input.value = current == null ? "" : String(current);
td.replaceChildren(input);
input.focus();
input.select();
let finished = false;
const finish = (save: boolean): void => {
const finish = (keep: boolean): void => {
if (finished) return;
finished = true;
const value = save ? parseCellInput(input.value, current) : current;
const empty = allowEmpty && input.value.trim() === "";
const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf);
if (value === undefined) {
const why = fill("Z01_MasterData_Edit_Invalid", input.value);
showToast(why, "error");
draw();
selectCell(index, column, why);
showDetail(why);
return;
}
if (value === current) {
draw();
return;
}
void commit(index, column, value);
if (keep) apply(value);
draw();
showDetail();
};
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") finish(true);
@@ -421,59 +526,87 @@ export function buildRowsView(): RowsView {
input.addEventListener("blur", () => finish(true));
}
/** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */
async function drop(index: number, column: MasterColumn, info: CellInfo): Promise<void> {
if (info.state === "meaning_changed") {
const ok = await showConfirmDialog(
`${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`,
L("Z01_MasterData_Edit_DropConfirm"),
);
if (!ok) return;
}
await commit(index, column, null);
function addRow(): void {
draft?.addRow();
draw();
}
/** 저장 — 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;
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);
}
draw();
}
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();
try {
const updated = await from.save(String(rowId), { [column.key]: value });
if (from !== source || table !== last) return;
table.rows[index] = updated;
draw();
selectCell(index, column);
showToast(
L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"),
"success",
);
await edit.save(active.payload());
active.clear();
showToast(L("Z01_MasterData_Saved"), "success");
void load();
} catch (error) {
const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed");
showToast(why, "error");
if (from !== source || table !== last) return;
draw();
selectCell(index, column, why);
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 show(picked: RowsSource): void {
source = picked;
/** [초기값으로] — 고른 줄을 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;
state.page = 1;
state.sort = null;
state.filters = {};
filterBox.replaceChildren();
title.textContent = picked.title;
titleKey.textContent = picked.key;
title.textContent = next.title;
titleKey.textContent = next.key;
notice.hidden = true;
banner.hidden = true;
search.root.hidden = picked.searchable === false;
search.root.hidden = next.searchable === false;
toolbar.hidden = false;
pager.hidden = false;
detail.hidden = true;
last = null;
selected = null;
picked = null;
draft = null;
stale = false;
actions.hidden = true;
grid.replaceChildren();
void load();
}
+27 -68
View File
@@ -12,25 +12,22 @@ import { t as L } from "@ui/ui_template_locale";
import {
fetchBasePrices,
fetchMasterRows,
fetchOverrides,
resetBasePrices,
saveBasePrices,
type MasterGroup,
type MasterRows,
type RowsQuery,
saveBasePrice,
VALUES_TABLE_ID,
type WorkItemAxis,
} from "./Z01_MasterData_Api_Fetch";
import {
groupKinds,
valueWithUnit,
type OverrideState,
type ServedKind,
} from "./Z01_MasterData_UI_Cells";
import { groupKinds, 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 => {
@@ -59,29 +56,32 @@ export function buildSidePanel(
title: `${box.label} ${served.label}`,
key: `base-prices/${served.kind}`,
load: (query) => fetchBasePrices(served.kind, query),
save: (rowId, values) => saveBasePrice(served.kind, rowId, values),
edit: {
scope: `base:${served.kind}`,
save: (body) => saveBasePrices(served.kind, body),
reset: (rowKeys) => resetBasePrices(served.kind, rowKeys),
},
});
});
grid.append(button);
}
return { label: box.label, grid };
});
const overridesButton = el("button", {
className: "z01-master__base z01-master__base--wide",
attrs: { type: "button" },
text: L("Z01_MasterData_Overrides"),
});
overridesButton.addEventListener("click", () => {
activate(overridesButton);
view.show({
title: L("Z01_MasterData_Overrides"),
key: "overrides",
searchable: false,
// 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임.
load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))),
// 공종 편집 — 구성 줄을 고치고 「입력 필요」 공종을 메움(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"),
});
});
boxes[0]?.grid.append(overridesButton);
button.addEventListener("click", () => {
activate(button);
openWorkItems(axis);
});
workGrid.append(button);
}
const tree = el("nav", { className: "z01-master__tree" });
for (const group of groups) {
@@ -132,54 +132,13 @@ 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,3 +315,204 @@
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);
}
@@ -0,0 +1,409 @@
/* =============================================================================
* 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 };
}
@@ -0,0 +1,374 @@
/* =============================================================================
* 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,11 +7,15 @@
빠지면 발파암(절취 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
@@ -292,3 +296,131 @@ 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,6 +76,7 @@ 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,
@@ -659,6 +660,7 @@ 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)
@@ -0,0 +1,542 @@
# -*- coding: utf-8 -*-
"""품셈 PDF → 절별 md 도구 (규칙: `원가계산/_품셈md_작성규칙.md` · 2026-09-19).
판정 기준은 PDF 원가계산(B09)·공종 마스터· md 보지 않음.
사용: python pum_md_tool.py plan <산림|건설> 차례에서 · 본문/ 파일(머리만 · 상태 대기)
python pum_md_tool.py pages <산림|건설> [쪽범위] 그림 150dpi PNG tmp/pum_pages/ (git )
python pum_md_tool.py check <md 파일> 대조 글자 대조 머리 기계검사
python pum_md_tool.py status 장별 대기/작성중/검증대기/확정
경계 = 시작표지·끝표지 글자가 안에서 놓인 (좌표로 묶은 줄의 ).
좌표로 묶기는 `resources/tester/test_const_pdf_coverage.py` 것을 그대로 ( 시험은 고침).
"""
from __future__ import annotations
import collections
import difflib
import hashlib
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4] # 저장소 뿌리
sys.path.insert(0, str(ROOT))
from resources.tester.test_const_pdf_coverage import _NUM, _page_rows # noqa: E402
COST = ROOT / "resources/knowledge/original/원가계산"
#: 책마다 — PDF · 차례 쪽(PDF 쪽 번호) · 인쇄 1쪽이 놓인 PDF 쪽 · 머리 「문서」 칸 · 새 md 뿌리
BOOKS = {
"산림": {
"pdf": COST / "산림_표준품셈/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
"toc": (3, 12),
"first_body": 13,
"doc": "산림사업 표준품셈(산림청고시 제2025-82호)",
"out": COST / "산림_표준품셈/본문",
},
}
HEAD_KEYS = (
"문서",
"",
"PDF쪽",
"인쇄쪽",
"시작표지",
"끝표지",
"상태",
"작성",
"검증",
"원천",
"기계검사",
"확정지문",
)
STATES = ("대기", "작성중", "검증대기", "확정")
_TOC_SKIP = {"", "내 용", "페이지", "- 목 차 -"}
_TOC_ID = re.compile(r"\d+장|\d+-\d+(?:-\d+)*|부록\d*")
_BAD_NAME = re.compile(r'[/:*?"<>|\\]')
_LEADER = re.compile(r"\s*[·․…]{3,}.*$")
#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽)
_EMPTY_PAGE_CHARS = 20
@dataclass
class Entry:
"""차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록."""
ident: str
name: str
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
]
return parse_toc(lines)
def parse_toc(raw: list[str]) -> list[Entry]:
lines = [
t for t in (_LEADER.sub("", line).strip() for line in raw) if t
] # 점선 뗌(「부록 ····」)
out, i = [], 0
while i < len(lines):
if not _TOC_ID.fullmatch(lines[i]):
raise ValueError(f"차례 모양이 다름: {lines[i]!r}")
j, name = i + 1, []
while j < len(lines) and not lines[j].isdigit():
name.append(lines[j])
j += 1
if j == len(lines):
raise ValueError(f"차례 쪽 번호 없음: {lines[i]}")
out.append(Entry(lines[i], " ".join(name), int(lines[j])))
i = j + 1
return out
#: 가운뎃점 여러 벌 — 차례는 「보수․복구」(U+2024) · 본문은 「보수·복구」(U+00B7). 맞대기에서만 같게 봄.
_DOTS = str.maketrans({"": "·", "": "·", "": "·", "": "·"})
def key(text: str) -> str:
"""표지 맞대기 — 공백·대괄호를 떼고 번호 뒤 점을 뗌(`9-5. 발파암` = `9-5 발파암`)."""
tight = re.sub(r"[\s\[\]]", "", text).translate(_DOTS)
return re.sub(r"^(\d+(?:-\d+)*)\.", r"\1", tight)
def locate(doc, marker: str, first_pdf: int, slack: int = 3) -> tuple[int, int]:
"""표지가 줄 맨 앞에 놓인 (PDF 쪽, 그 쪽 안 줄 차례) — first_pdf 부터 slack 쪽 안에서 찾음."""
want = key(marker)
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 key(row).startswith(want):
return number, index
raise ValueError(f"표지를 못 찾음: {marker} (PDF {first_pdf}~{first_pdf + slack})")
def heading_pattern(ident: str) -> 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)")
#: 글자 사이 빈칸으로 볼 틈(글자 크기 대비) — 본문 머리 실측: 빈칸 7.7pt · 붙은 글자 0.7pt(14pt 글꼴)
_GAP = 0.25
def visual_text(page, row: str) -> str:
"""쪽 그림대로 띄운 줄 — 글자층은 빈칸을 잃어서(「설계및수량」) 글자 사이 틈으로 되살림."""
want = re.sub(r"\s", "", row)
for block in page.get_text("rawdict")["blocks"]:
for line in block.get("lines", []):
chars = [c for span in line["spans"] for c in span["chars"]]
if re.sub(r"\s", "", "".join(c["c"] for c in chars)) != want:
continue
size = max(span["size"] for span in line["spans"])
out = ""
for prev, ch in zip([None, *chars], chars):
gap = ch["bbox"][0] - prev["bbox"][2] if prev else 0
if prev and not prev["c"].isspace() and gap > size * _GAP:
out += " "
out += ch["c"]
return " ".join(out.split())
return row
def find_heading(doc, ident: str, first_pdf: int, slack: int = 3) -> tuple[int, int, str]:
"""(PDF 쪽, 쪽 안 줄 차례, 머리 글자) — 머리 글자는 PDF 그대로(번호 뒤 마침표 포함) · 띄어쓰기는 쪽 그림대로."""
pattern = heading_pattern(ident)
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())
raise ValueError(f"머리를 못 찾음: {ident} (PDF {first_pdf}~{first_pdf + slack})")
def name_of(ident: str, title: str) -> str:
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
return title[heading_pattern(ident).match(title).end() :].strip()
def claimed_elsewhere(root: Path) -> set[str]:
"""다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘."""
import subprocess
rel = root.relative_to(ROOT).as_posix()
refs = subprocess.run(
["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"],
capture_output=True,
text=True,
check=True,
).stdout.split()
out: set[str] = set()
for ref in refs:
listed = subprocess.run(
[
"git",
"-C",
str(ROOT),
"-c",
"core.quotepath=off",
"ls-tree",
"-r",
"--name-only",
ref,
"--",
rel,
],
capture_output=True,
text=True,
encoding="utf-8",
)
out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line)
return out # root 아래 상대 경로
def safe(text: str) -> str:
return _BAD_NAME.sub("", text).replace(" ", "_")
def _span(a: int, b: int) -> str:
return str(a) if a == b else f"{a}~{b}"
def head_text(values: dict[str, str]) -> str:
return (
"---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n"
)
def read_head(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0] != "---":
raise ValueError(f"머리 없음: {path}")
out = {}
for line in lines[1:]:
if line == "---":
return out
k, _, v = line.partition(":")
out[k.strip()] = v.strip()
raise ValueError(f"머리 끝 없음: {path}")
_SLOT = re.compile(r"제(\d+)장_[^/]*/(\d+-\d+)_")
def plan(book_name: str) -> list[Path]:
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기).
**이미 있는 자리는 건드림** 폴더에 있거나 다른 브랜치에 있는 ·(이름이 달라도 번호로 ).
"""
import pymupdf
book = BOOKS[book_name]
out = book["out"]
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))}
written = []
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
for n, entry in enumerate(bounds):
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)
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))}"
)
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)
return written
# ── check ──────────────────────────────────────────────────────────────
#: 쪽 번호 줄(「- 123 -」) — 절 글자에서 뺌
_PAGE_NO = re.compile(r"^-\s*\d+\s*-$")
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
_COMMENT = re.compile(r"<!--.*?-->", re.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"
def book_of(path: Path) -> dict:
path = path.resolve()
for book in BOOKS.values():
if path.is_relative_to(book["out"].resolve()):
return book
raise ValueError(f"본문/ 아래 파일이 아님: {path}")
def split_head(text: str) -> tuple[str, str]:
"""(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지."""
lines = text.split("\n")
end = lines.index("---", 1)
return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :])
def md_lines(body: str) -> list[tuple[int, str]]:
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터."""
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
out = []
for number, line in enumerate(body.split("\n"), start=1):
if _TABLE_RULE.match(line):
continue
line = _IMAGE.sub("", line).replace("<br>", " ")
held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
line = _ESCAPED.sub(_HOLD, line)
line = re.sub(r"^\s*#+\s", "", line) # 제목
line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
for ch in held:
line = line.replace(_HOLD, ch, 1)
out.append((number, line))
return out
def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
"""그 절 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)
end_page, end_row = last + 1, 0
if head["끝표지"] and head["끝표지"] != "":
try:
end_page, end_row = locate(doc, head["끝표지"], last, slack=1)
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])):
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))
return out
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄)."""
text, where = [], []
for at, line in lines:
for ch in re.sub(r"\s", "", line):
text.append(ch)
where.append(at)
return "".join(text), where
def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]:
counts, where = collections.Counter(), collections.defaultdict(list)
for at, line in lines:
for token in _NUM.findall(line):
counts[token] += 1
where[token].append(at)
return counts, where
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향."""
pdf_n, pdf_at = _numbers(pdf)
md_n, md_at = _numbers(md)
pdf_t, pdf_where = _tight(pdf)
md_t, md_where = _tight(md)
missing_c = collections.Counter(pdf_t) - collections.Counter(md_t)
extra_c = collections.Counter(md_t) - collections.Counter(pdf_t)
spots = []
if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만)
matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False)
for op, i1, i2, j1, j2 in matcher.get_opcodes():
gone, added = pdf_t[i1:i2], md_t[j1:j2]
if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)):
continue
spots.append(
{
"pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None,
"md": md_where[min(j1, len(md_where) - 1)] if md_where else None,
"context": pdf_t[max(0, i1 - 8) : i1],
"pdf_only": gone,
"md_only": added,
}
)
return {
"num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()},
"num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()},
"char_missing": missing_c,
"char_extra": extra_c,
"spots": spots,
}
def verdict(result: dict) -> str:
counts = (
sum(v for v, _ in result["num_missing"].values()),
sum(v for v, _ in result["num_extra"].values()),
sum(result["char_missing"].values()),
sum(result["char_extra"].values()),
)
word = "통과" if not any(counts) else "불통"
return (
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
)
def fingerprint(text: str) -> str:
"""확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌."""
lines = text.replace("\r\n", "\n").split("\n")
kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))]
return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest()
def set_head(text: str, field: str, value: str) -> str:
head, body = split_head(text)
head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1)
return head + "\n" + body
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))
return result, verdict(result)
def check(path: Path) -> int:
"""검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
있는 확정지문은 고침 확정 바뀐 파일을 지문을 다시 적어 덮으면 시험이 잡음.
"""
result, line = check_file(path)
print(f"{path.name}: {line}")
for token, (count, at) in sorted(result["num_missing"].items()):
print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
for token, (count, at) in sorted(result["num_extra"].items()):
print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
for spot in result["spots"][:40]:
print(
f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
)
text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
head = read_head(path)
if head.get("상태") == "확정" and line.startswith("통과") and not head.get("확정지문"):
text = set_head(text, "확정지문", fingerprint(text))
path.write_text(text, encoding="utf-8", newline="\n")
return 0 if line.startswith("통과") else 1
# ── pages · status ─────────────────────────────────────────────────────
PAGES_DIR = ROOT / "tmp/pum_pages"
def pages(book_name: str, span: str = "") -> list[Path]:
"""쪽 그림 150dpi PNG — `tmp/pum_pages/<책>/p0135.png`(git 밖)."""
import pymupdf
out = PAGES_DIR / book_name
out.mkdir(parents=True, exist_ok=True)
made = []
with pymupdf.open(BOOKS[book_name]["pdf"]) as doc:
first, _, last = (span or f"1~{doc.page_count}").replace("-", "~").partition("~")
for number in range(int(first), int(last or first) + 1):
path = out / f"p{number:04d}.png"
doc[number - 1].get_pixmap(dpi=150).save(path)
made.append(path)
return made
def status() -> str:
"""장별 상태 표(md) — 머리 「상태」 칸을 모음."""
rows = [
"| 책 | 장 | " + " | ".join(STATES) + " | 계 |",
"|---|---|" + "---|" * (len(STATES) + 1),
]
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]
by_chapter.setdefault(chapter, collections.Counter())[
read_head(path).get("상태", "")
] += 1
for chapter, counts in by_chapter.items():
cells = " | ".join(str(counts[s]) for s in STATES)
rows.append(f"| {name} | {chapter} | {cells} | {sum(counts.values())} |")
return "\n".join(rows)
def main(argv: list[str]) -> int:
command, args = (argv[0], argv[1:]) if argv else ("", [])
if command == "plan" and args:
print(f"새 파일 {len(plan(args[0]))}")
return 0
if command == "pages" and args:
made = pages(args[0], args[1] if len(args) > 1 else "")
print(f"쪽 그림 {len(made)}{PAGES_DIR / args[0]}")
return 0
if command == "check" and args:
return max(check(Path(a)) for a in args)
if command == "status":
print(status())
return 0
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 제1장 적용기준
PDF쪽: 13
인쇄쪽: 1
시작표지: 제1장 적용기준
끝표지: 1-1. 일반사항
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-1. 일반사항
PDF쪽: 13~14
인쇄쪽: 1~2
시작표지: 1-1. 일반사항
끝표지: 1-2. 설계 및 수량
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-2. 설계 및 수량
PDF쪽: 14~27
인쇄쪽: 2~15
시작표지: 1-2. 설계 및 수량
끝표지: 1-3. 재료 및 노임의 할증
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-3. 재료 및 노임의 할증
PDF쪽: 27~29
인쇄쪽: 15~17
시작표지: 1-3. 재료 및 노임의 할증
끝표지: 1-4. 품의 할인․할증
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-4. 품의 할인․할증
PDF쪽: 29~36
인쇄쪽: 17~24
시작표지: 1-4. 품의 할인․할증
끝표지: 1-5. 목재 원목시가
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-5. 목재 원목시가
PDF쪽: 36
인쇄쪽: 24
시작표지: 1-5. 목재 원목시가
끝표지: 1-6. 원가 작성기준
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-6. 원가 작성기준
PDF쪽: 36~37
인쇄쪽: 24~25
시작표지: 1-6. 원가 작성기준
끝표지: 1-7. 기타사항
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 1-7. 기타사항
PDF쪽: 38~39
인쇄쪽: 26~27
시작표지: 1-7. 기타사항
끝표지: 제2장 소요재료 및 기계손료
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 제2장 소요재료 및 기계손료
PDF쪽: 40
인쇄쪽: 28
시작표지: 제2장 소요재료 및 기계손료
끝표지: 2-1. 소요재료
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 2-1. 소요재료
PDF쪽: 40~47
인쇄쪽: 28~35
시작표지: 2-1. 소요재료
끝표지: 2-2. 기계손료
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 2-2. 기계손료
PDF쪽: 48~51
인쇄쪽: 36~39
시작표지: 2-2. 기계손료
끝표지: 제3장 작업장 관리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 제3장 작업장 관리
PDF쪽: 52
인쇄쪽: 40
시작표지: 제3장 작업장 관리
끝표지: 3-1. 경계표시
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-1. 경계표시
PDF쪽: 52
인쇄쪽: 40
시작표지: 3-1. 경계표시
끝표지: 3-2. 작업로 선정
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-2. 작업로 선정
PDF쪽: 52
인쇄쪽: 40
시작표지: 3-2. 작업로 선정
끝표지: 3-3. 작업로 설치
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-3. 작업로 설치
PDF쪽: 52~53
인쇄쪽: 40~41
시작표지: 3-3. 작업로 설치
끝표지: 3-4. 임산물 운반로 신설 및 보수·복구
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-4. 임산물 운반로 신설 및 보수·복구
PDF쪽: 53~55
인쇄쪽: 41~43
시작표지: 3-4. 임산물 운반로 신설 및 보수·복구
끝표지: 3-5. 예정지정리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-5. 예정지정리
PDF쪽: 56~57
인쇄쪽: 44~45
시작표지: 3-5. 예정지정리
끝표지: 3-6. 산물 임내정리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-6. 산물 임내정리
PDF쪽: 57
인쇄쪽: 45
시작표지: 3-6. 산물 임내정리
끝표지: 3-7. 재해산물 수집
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-7. 재해산물 수집
PDF쪽: 58
인쇄쪽: 46
시작표지: 3-7. 재해산물 수집
끝표지: 3-8. 드론 영상 촬영
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 3-8. 드론 영상 촬영
PDF쪽: 58
인쇄쪽: 46
시작표지: 3-8. 드론 영상 촬영
끝표지: 제4장 나무베기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,39 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-2. 뿌리돌림
PDF쪽: 66
인쇄쪽: 54
시작표지: 5-2. 뿌리돌림
끝표지: 5-3. 식재작업
상태: 확정
작성: sub_laptop_3
검증: sub_laptop_2
원천: 글자층
기계검사:
확정지문:
---
## 5-2. 뿌리돌림
(인/본당)
| 근원직경(㎝) | 수량 | | 근원직경(㎝) | 수량 | |
|---|---|---|---|---|---|
| | 특별인부 | 보통인부 | | 특별인부 | 보통인부 |
| 3 | 0.03 | 0.01 | 36 | 1.86 | 0.22 |
| 5 | 0.06 | 0.01 | 42 | 2.04 | 0.25 |
| 7 | 0.11 | 0.01 | 48 | 2.32 | 0.28 |
| 9 | 0.17 | 0.02 | 54 | 2.79 | 0.33 |
| 11 | 0.23 | 0.03 | 60 | 3.07 | 0.36 |
| 13 | 0.30 | 0.03 | 66 | 4.18 | 0.50 |
| 15 | 0.37 | 0.05 | 72 | 4.65 | 0.55 |
| 18 | 0.56 | 0.06 | 78 | 5.21 | 0.62 |
| 21 | 0.65 | 0.08 | 84 | 6.51 | 0.78 |
| 24 | 0.74 | 0.09 | 90 | 7.06 | 0.85 |
| 30 | 1.58 | 0.19 | 100 | 7.90 | 0.95 |
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「근원직경(㎝)」 · 머리 2~3열 「수량」 · 4열 머리~몸 1행 「근원직경(㎝)」 · 머리 5~6열 「수량」 -->
[주]
① 뿌리돌림은 수목 이식 전에 뿌리 분 밖으로 돌출된 뿌리를 깨끗이 절단하여 주근 가까운 곳의 측근과 잔뿌리의 발달을 촉진시키는 작업이다.
② 분은 근원직경의 4∼5배로 한다.
③ 뿌리 절단 부위의 보호를 위한 재료비는 별도 계상한다.
@@ -0,0 +1,177 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-3. 식재작업
PDF쪽: 67~70
인쇄쪽: 55~58
시작표지: 5-3. 식재작업
끝표지: 5-4. 파종조림
상태: 확정
작성: sub_laptop_3
검증: sub_laptop_2
원천: 글자층
기계검사:
확정지문:
---
## 5-3. 식재작업
### 5-3-1. 나무식재
(인/1,000본당)
| 구분 | 규격 | 소요인력 | 인력구분 |
|---|---|---|---|
| 소묘식재 | 소묘 | 3.50 | 특별인부 30%<br>보통인부 70% |
| 중묘식재 | 중묘 | 4.00 | |
| 대묘식재 | 대묘 | 5.00 | |
<!-- 병합: 4열 1~3행 「특별인부 30%<br>보통인부 70%」 -->
[주]
① 본 품은 구덩이 파기, 묘목심기와 뒷정리를 포함한다. 묘목 운반비는 별도로 적용한다.
② 보식의 경우에는 나무식재 품의 0.05인/100본당을 가산할 수 있다.
③ 묘목의 규격은 「종묘사업 실시요령」 (산림청 예규) 별표 8 "산림용 묘목규격표"의 수종별 묘령 및 형태에 따라 다음과 같이 적용한다.
③ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6), 토양상태(1-4-10)를 반영한다.
<!-- 원문 그대로: 번호 ③ 이 두 번(묘목의 규격 · 할인․할증) -->
| 수종별 | 소묘 | 중묘 | 대묘 |
|---|---|---|---|
| 소나무 | 1-1(노) | 2-0(용) | 1-1-2(노), 2-2(용) |
| 낙엽송 | | 1-1, 2-0(용) | |
| 잣나무 | 2-1(노) | 2-2(노) | 2-2-3(노) |
| 편백 | | 1-1(노), 1-1-1(노), 2-0(용) | 1-1-2, 2-2(용) |
| 삼나무 | | 1-1(노) | |
| 리기테다소나무 | 1-0(노) | | |
| 백합나무 | 1-0(노), 1-1(노) | | |
| 가시나무류 | 2-0(용) | | |
| ※ 위 표에서 ‘노’는 ‘노지묘’이고, ‘용’은 ‘용기묘’이다.<br>※ 그 밖의 수종은 수종별 간장(최소 간장을 말한다) 및 근원경을 기준으로 아래와 같이 적용하되, 활엽수 삽목묘ㆍ접목묘 대묘는 이를 중묘로 본다. | | | |
<!-- 병합: 9행 1~4열 「※ 두 줄」 · 아래 표(구분·침엽수·활엽수)도 이 칸 안, ※ 두 줄 밑에 있음 -->
| 구분 | 침엽수 | 활엽수 |
|---|---|---|
| 소묘 | 간장 20cm미만 | 간장 30cm미만 |
| 중묘 | 간장 20cm이상 40cm미만 또는<br>간장 40cm이상이고 근원경 8mm미만 | 간장 30cm이상 60cm미만 또는<br>간장 60cm이상이고 근원경 11mm미만 |
| 대묘 | 간장 40cm이상이고 근원경 8mm이상 | 간장 60cm이상이고 근원경 11mm이상 |
④ 묘목의 가격은 「산림자원의 조성 및 관리에 관한 법률 시행령」 제16조 규정에 따라 본 품과 별도로 적용한다.
### 5-3-2. 관목식재(단식)
(인/10본당)
| 구분 | 수량(수고기준) | | | |
|---|---|---|---|---|
| | 0.3m 미만 | 0.30.7m 이하 | 0.81.1m 이하 | 1.21.5m 이하 |
| 특별인부 | 0.19 | 0.24 | 0.40 | 0.57 |
| 보통인부 | 0.06 | 0.08 | 0.13 | 0.18 |
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「구분」 · 머리 2~5열 「수량(수고기준)」 -->
[주]
① 본 품은 근원부에서 분지되어 다년생으로 자라는 관목수종의 식재 기준이다.
② 터파기, 가지치기, 나무세우기, 묻기, 물주기, 손질, 뒷정리 작업을 포함한다.
③ 나무높이가 1.5m를 초과할 때는 나무높이에 비례하여 할증할 수 있다.
④ 나무높이보다 수관폭이 더 클 때에는 그 수관폭을 나무높이로 본다.
⑤ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.
⑥ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.
⑦ 암반식재, 부적기식재 등 특수식재는 품을 별도 계상할 수 있다.
### 5-3-3. 관목식재(군식)
(인/10본당)
| 구분 | 수량(수고기준) | | | |
|---|---|---|---|---|
| | 0.3m 미만 | 0.30.7m 이하 | 0.81.1m 이하 | 1.21.5m 이하 |
| 특별인부 | 0.07 | 0.10 | 0.15 | 0.21 |
| 보통인부 | 0.02 | 0.03 | 0.05 | 0.07 |
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「구분」 · 머리 2~5열 「수량(수고기준)」 -->
[주]
① 본 품은 근원부에서 분지되어 다년생으로 자라는 관목수종의 식재 기준이다.
② 터파기, 가지치기, 나무세우기, 묻기, 물주기, 손질, 뒷정리 작업을 포함한다.
③ 나무높이가 1.5m를 초과할 때는 나무높이에 비례하여 할증할 수 있다.
④ 나무높이보다 수관폭이 더 클 때에는 그 수관폭을 나무높이로 본다.
⑤ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.
⑥ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.
⑦ 암반식재, 부적기식재 등 특수식재는 품을 별도 계상할 수 있다.
⑧ 군식은 일반적으로 아래의 식재밀도 이상인 경우이다.
<!-- 원문 그대로: 「아래의 식재밀도」 표가 없음 — 이 줄에서 56쪽이 끝나고 57쪽은 5-3-4 로 시작 -->
### 5-3-4. 교목식재(나무높이)
(인/본당)
| 나무높이(m) | 수량 | | | | |
|---|---|---|---|---|---|
| | 인력시공 | | 기계시공 | | |
| | 특별인부(인) | 보통인부(인) | 특별인부(인) | 보통인부(인) | 굴착기(시간) |
| 1.0 이하 | 0.07 | 0.06 | - | - | - |
| 1.1 1.5 | 0.09 | 0.07 | - | - | - |
| 1.6 2.0 | 0.11 | 0.09 | - | - | - |
| 2.1 2.5 | 0.15 | 0.12 | 0.10 | 0.06 | 0.19 |
| 2.6 3.0 | 0.19 | 0.14 | 0.11 | 0.07 | 0.23 |
| 3.1 3.5 | 0.23 | 0.17 | 0.13 | 0.07 | 0.26 |
| 3.6 4.0 | 0.29 | 0.20 | 0.15 | 0.08 | 0.31 |
| 4.1 4.5 | 0.33 | 0.23 | 0.16 | 0.09 | 0.35 |
| 4.6 5.0 | 0.38 | 0.27 | 0.17 | 0.10 | 0.40 |
| 비고 | - 지주목을 세우지 않을 때는 다음의 요율을 감한다. | | | | |
<!-- 머리: 몸 2행까지 머리 · 병합: 1열 머리~몸 2행 「나무높이(m)」 · 머리 2~6열 「수량」 · 1행 2~3열 「인력시공」 · 1행 4~6열 「기계시공」 · 12행 2~6열 비고 칸 — 아래 작은 표도 이 칸 안에 있음 -->
| 인력시공시 | 기계시공시 |
|---|---|
| 인력품의 10% | 인력품의 20% |
[주]
① 본 품은 흉고 또는 근원직경을 추정하기 어려운 수종에 적용한다.
② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.
③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.
④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.
⑤ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.
⑥ 현장의 시공조건, 수목의 성상에 따라 기계 시공이 불가피한 경우는 별도 계상한다.
⑦ 굴착기 규격은 0.4㎥를 기준으로 한다.
### 5-3-5. 교목식재(흉고직경)
(인/본당)
| 흉고(근원)직경(㎝) | 수량 | | | |
|---|---|---|---|---|
| | 특별인부(인) | 보통인부(인) | 굴착기(시간) | 크레인(시간) |
| 4(5)이하 | 0.10 | 0.06 | - | - |
| 5(6) | 0.17 | 0.08 | - | - |
| 6 7(7 8) | 0.26 | 0.13 | - | - |
| 8 9(9 11) | 0.19 | 0.11 | 0.37 | - |
| 10 11(12 13) | 0.24 | 0.13 | 0.43 | - |
| 12 14(14 17) | 0.31 | 0.15 | 0.52 | - |
| 15 17(18 20) | 0.39 | 0.17 | 0.64 | - |
| 18 19(21 23) | 0.47 | 0.20 | 0.72 | 0.21 |
| 20 24(24 29) | 0.56 | 0.22 | 0.85 | 0.26 |
| 25 29(30 35) | 0.69 | 0.26 | 1.03 | 0.34 |
| 30 34(36 41) | 0.83 | 0.30 | 1.21 | 0.42 |
| 35 39(42 47) | 0.97 | 0.35 | 1.39 | 0.50 |
| 40 44(48 53) | 1.11 | 0.38 | 1.56 | 0.58 |
| 45 49(54 59) | 1.24 | 0.43 | 1.75 | 0.66 |
| 50(60) | 1.33 | 0.45 | 1.85 | 0.70 |
| 비고 | - 지주목을 세우지 않을 때는 다음의 요율을 감한다. | | | |
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「흉고(근원)직경(㎝)」 · 머리 2~5열 「수량」 · 17행 2~5열 비고 칸 — 아래 작은 표도 이 칸 안에 있음 -->
| 인력시공시 | 기계시공시 |
|---|---|
| 인력품의 10% | 인력품의 20% |
[주]
① 본 품은 교목류 수종에 적용한다.
② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.
③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.
④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.
⑤ 흉고직경은 지표면에서 높이 1.2m 부위의 나무줄기 지름이다.
⑥ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.
⑦ 현장의 시공조건, 수목의 성상에 따라 기계시공이 불가피한 경우는 별도 계상한다.
⑧ 장비 규격은 다음을 기준으로 한다.
| 흉고직경(cm) | 굴착기(㎥) | 크레인 |
|---|---|---|
| 8 17 | 0.4 | - |
| 18 22 | 0.6 | 트럭탑재형 크레인 10ton |
| 23 34 | 0.6 | 트력탑재형 크레인 15ton |
| 35 50 | 0.6 | 크레인(타이어) 25 50ton |
<!-- 원문 그대로: 3행 「트력탑재형」(2행은 「트럭탑재형」) -->
@@ -0,0 +1,33 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-4. 파종조림
PDF쪽: 71
인쇄쪽: 59
시작표지: 5-4. 파종조림
끝표지: 5-5. 천연하종갱신
상태: 확정
작성: sub_laptop_3
검증: sub_laptop_2
원천: 글자층
기계검사:
확정지문:
---
## 5-4. 파종조림
(인/ha당)
| 구분 | 내용 | 소요인력<br>(인/ha) | 인력구분 |
|---|---|---|---|
| 파종상 만들기 | 40cm×40cm×5,000개 | 6 | 보통인부 |
| | 80cm×80cm×5,000개 | 11 | |
| 파종상에 점파 | 5,000판×3립 | 7.2 | |
| 파종상 없이 | 조파 | 9.2 | |
| | 점파 | 7.2 | |
<!-- 병합: 1열 1~2행 「파종상 만들기」 · 1열 4~5행 「파종상 없이」 · 4열 1~5행 「보통인부」 -->
[주]
① 파종상 만들기란, 종자파종 할 지역의 지피물을 제거하고 토양을 괭이나 삽으로 경운하여 돌이나 잡초목의 뿌리 등을 제거한 후 10㎝ 높이로 상을 만드는 작업을 말한다.
② 종자파종 품에는 파종 후 종자지름의 2∼3배 흙을 복토하고 망사, 플라스틱 원통 또는 종이컵 등으로 방조물을 설치하는 작업을 포함한다.
③ 예정지정리가 필요한 경우에는 "3-5 예정지 정리작업"을 적용한다.
④ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6), 토양상태(1-4-10)를 반영한다.
@@ -0,0 +1,29 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-5. 천연하종갱신
PDF쪽: 71
인쇄쪽: 59
시작표지: 5-5. 천연하종갱신
끝표지: 5-6. 움싹갱신
상태: 확정
작성: sub_laptop_3
검증: sub_laptop_2
원천: 글자층
기계검사:
확정지문:
---
## 5-5. 천연하종갱신
(인/ha당)
| 구분 | 내용 | 소요인력<br>(인/ha) | 인력구분 |
|---|---|---|---|
| 지면긁기작업 | 폭 3040cm × 열간거리 2m<br>(전면적의 1520%) | 8 | 보통인부 |
| | 폭 80cm × 열간거리 2m<br>(전면적의 40%) | 15 | |
<!-- 병합: 1열 1~2행 「지면긁기작업」 · 4열 1~2행 「보통인부」 -->
[주]
① 지면긁기작업이란, 떨어진 종자가 잘 발아할 수 있도록 등고선 방향으로 지피물을 제거하고 토양 광물층이 노출되도록 하는 작업을 말하며, 모수에서 종자가 떨어지기 전에 실시한다.
② 예정지정리가 필요한 경우에는 "3-5. 예정지 정리작업"을 적용한다.
③ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6), 토양상태(1-4-10)를 반영한다.
@@ -0,0 +1,28 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-6. 움싹갱신
PDF쪽: 71
인쇄쪽: 59
시작표지: 5-6. 움싹갱신
끝표지: 5-7. 생태보완조림
상태: 확정
작성: sub_laptop_3
검증: sub_laptop_2
원천: 글자층
기계검사:
확정지문:
---
## 5-6. 움싹갱신
(인/ha당)
| 구분 | 내용 | 소요인력<br>(인/ha) | 인력구분 |
|---|---|---|---|
| 맹아근주 정리작업 | 맹아근주 900본 기준 | 4.95 | 특별인부 50%<br>보통인부 50% |
[주]
① 맹아근주 정리작업이란, 벌채면을 평활하고 약간 기울게 하여 물이 고이지 않도록 잘라주는 작업을 말한다.
② 본 품에는 움싹이 잘 발생할 수 있도록 벌근 주위를 정리하는 작업이 포함한다.
③ 예정지정리가 필요한 경우에는 "3-5. 예정지 정리작업"을 적용한다.
④ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6)를 반영한다.
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-7. 생태보완조림
PDF쪽: 72
인쇄쪽: 60
시작표지: 5-7. 생태보완조림
끝표지: 5-8. 큰나무 공익조림
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-8. 큰나무 공익조림
PDF쪽: 72
인쇄쪽: 60
시작표지: 5-8. 큰나무 공익조림
끝표지: 5-9. 해안조림
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-9. 해안조림
PDF쪽: 72~73
인쇄쪽: 60~61
시작표지: 5-9. 해안조림
끝표지: 5-10. 사방조림
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-10. 사방조림
PDF쪽: 73
인쇄쪽: 61
시작표지: 5-10. 사방조림
끝표지: 5-11. 사초심기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,36 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-11. 사초심기
PDF쪽: 73~74
인쇄쪽: 61~62
시작표지: 5-11. 사초심기
끝표지: 5-12. 떼붙임(재배잔디)
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-11. 사초심기
(인/100본당)
| 구분 | 사초굴취 | 사초식재 | 비고 |
|---|---|---|---|
| 보통인부(인) | 0.43 | 0.72 | |
[주]
① 사초심기는 퇴사울타리와 정사울타리가 부식된 후 이들의 기능을 보충하기 위하여 화본과(禾本科), 사초과(莎草科) 또는 국화과(菊花科) 등에 속하는 초본류 중 내풍성, 내염성이 강하고 퇴사에 의한 매몰, 건조, 더위에 강하여 모래땅에서 잘 생육하는 사초(砂草)를 식재하여 사면을 피복하고 비사를 고정하는 공법이다.
② 본 품은 다발심기, 줄심기에 공통으로 적용한다.
③ 식혈의 크기 깊이 30㎝, 직경 30㎝를 기준으로 하는 품이다.
④ 다발심기는 사초를 4~8포기씩 모아 한 다발로 만들고 30~50㎝ 간격으로 심는다.
⑤ 줄심기는 1~2주씩 1열로, 주(株)간거리 4~5㎝, 열간거리 30~40㎝로 심고 줄심기의 줄 방향은 주풍방향에 직각으로 배치한다.
⑥ 사초의 운반은 조견표에 의한 별도 계상한다.
⑦ 모래의 퇴적으로 말라죽지 않고, 기는 줄기나 땅속줄기가 잘 뻗어 많은 가지가 발생하며 모래층을 잘 긴박하는 수종으로 식재한다.
\* 화본과 : 갯개고리풀, 갯쇠보리, 새, 솔새
\* 사초과 : 보리사초, 통보리사초, 솔보리사초, 행부자(숨복사지)
\* 국화과 : 갯쑥부쟁이, 갯쑥, 갯씀바귀, 갯상근, 갯메꽃, 갯질경, 모래지치, 갯보리, 큰개미자리, 자귀풀
\* 콩과 : 갯완두
\* 도입 초종 : american beach grass, weeping love grass, switch grass
@@ -0,0 +1,30 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-12. 떼붙임(재배잔디)
PDF쪽: 74
인쇄쪽: 62
시작표지: 5-12. 떼붙임(재배잔디)
끝표지: 5-13. 떼심기
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-12. 떼붙임(재배잔디)
(인/100㎡)
| 구분 | 보통인부(인) | 비고 |
|---|---|---|
| 줄떼 | 4.05.0 | |
| 평떼 | 5.07.0 | |
[주]
① 본 품은 재배잔디를 붙이는 품으로 재료의 소운반, 흙고르기, 홈파기, 뗏밥주기, 관수 및 마무리 품이 포함된다.
② 산복 절토면에서는 자갈, 초목의 제거비용은 별도 계상할 수 있다.
③ 산지 대상지 평균 비탈구배(20˚~ 45˚이하) 시공시 품의 20%를 할증하고, 떼꽂이 만들기와 박기품을 포함한다(떼꽂이는 떼 각 1장에 대하여 실시한다).
④ 성토면 시공시 흙다지기가 필요할 때 건설품셈의 품을 적용할 수 있다.
⑤ 떼 값 및 운반은 별도 계상한다.
@@ -0,0 +1,30 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-13. 떼심기
PDF쪽: 74~75
인쇄쪽: 62~63
시작표지: 5-13. 떼심기
끝표지: 5-14. 새심기
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-13. 떼심기
(인/100㎡당)
| 구분 | 보통인부(인) | 비고 |
|---|---|---|
| 줄떼심기 | 4.5 | 평탄지용 |
| 띠떼심기 | 6.0 | 경사지용 |
[주]
① 줄떼심기는 평탄지에 골을 파고 20∼30㎝ 떼를 심는 것을 말하고, 띠떼심기는 주로 경질토 땅깎기 비탈에서 수평으로 가늘고 긴 골을 파고 골속에 흙이 떨어지지 않은 반떼를 끼워 넣는 공법을 말한다.
② 본 품은 재배 및 인공잔디를 심는 품으로 재료의 소운반, 흙고르기, 홈파기, 복토, 관수 및 마무리 품이 포함되어 있다.
③ 산복 절토면에서는 면고르기 품을 별도 계상할 수 있다.
④ 띠떼, 줄떼심기는 30㎝ 줄간격으로 10㎝ 폭의 떼를 골파기 후 심는 것으로 토질에 따른 골파기품, 떼 값은 별도 계상한다.
⑤ 떼 자르기품은 포함되어 있다.
@@ -0,0 +1,35 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-14. 새심기
PDF쪽: 75
인쇄쪽: 63
시작표지: 5-14. 새심기
끝표지: 5-15. 바자얽기
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-14. 새심기
(㎡당)
| 공정별 | 단위 | 수량 | 비고 |
|---|---|---|---|
| 새채집 | ㎡ | 1 | 1㎡당 10주, 1주는 5본기준 |
| 요소 | kg | 0.02 | |
| 인산 | kg | 0.126 | |
| 보통인부 (채집) | 인 | 0.0328 | |
| 보통인부 (심기) | 인 | 0.0328 | |
| 새운반 | ㎡ | 1 | 조견표에 의함 |
[주]
① 녹화공사의 보완, 부토고정, 수로와 연접한 좌, 우 사면고정을 위하여 새류(새, 솔새, 개솔새, 억새 등)의 풀포기를 식재하는 것을 말한다.
② 본 품은 자연 상태의 새를 채취하여 심는 품으로 점심기의 주당 간격은 20∼30㎝ 정도로 하고, 줄심기는 80∼100㎝ 간격으로 심는다.
③ 새 운반거리는 200m까지 조견표에 의한다.
④ 본 품에는 심은 후 다짐 품이 포함되어 있다.
⑤ 본 품은 근계부분이식, 새의 지상부와 근계부분이식에 동일하게 사용한다.
⑥ 작업반장은 1인/보통인부 20인을 계상한다.
@@ -0,0 +1,32 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-15. 바자얽기
PDF쪽: 75~76
인쇄쪽: 63~64
시작표지: 5-15. 바자얽기
끝표지: 5-16. 선떼붙이기공
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-15. 바자얽기
(10m당)
| 명칭 | 규격 | 수량 | 단위 | 비고 |
|---|---|---|---|---|
| 말뚝 | 직경4~6㎝, 길이120㎝ 기준 | 20 | 개 | |
| 보통인부 | | 3.26 | 인 | 단절고 0.59m, 계단폭 0.7m |
| 특별인부 | | 0.22 | 인 | 말뚝 1m당 2개 사용 |
[주]
① 바자얽기는 산지 비탈면에서 토양침식으로 인한 토사의 유출과 붕괴방지 및 식생조성을 목적으로 비탈면 또는 계단상에 바자(編柵, wicker)를 설치하고, 뒤쪽에 흙을 채워 식생을 조성하는 산비탈 녹화 사방공작물이다.
② 말구지름 8~10㎝ 내외, 길이 1.0~1.5m의 나무말뚝을 0.5~1.0m 간격으로 길이의 6할 이상을 땅에 박고, 초두목이나 가지로 높이 50㎝ 정도의 바자를 얽어맨다.
③ 말뚝을 박는 각도는 사면에 직각 방향과 수직선과의 이등분선이 되도록 시공함을 원칙으로 하나 경사가 완만한 경우에는 수직으로 하여도 된다.
④ 바자를 얽어매는 나뭇가지는 밑부분 지름이 3㎝ 이하, 1m당 0.30.5속 (1속 : 25본 내외)을 사용하며, 잘 휘어지는 나무를 사용한다.
⑤ 바자 앞면에 버드나무류의 삽수를 꽂아 토양을 고정하도록 한다.
⑥ 바자얽기 상하 간격은 직고 0.5~1.0m로 한다.
@@ -0,0 +1,56 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-16. 선떼붙이기공
PDF쪽: 76
인쇄쪽: 64
시작표지: 5-16. 선떼붙이기공
끝표지: 5-17. 조공
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-16. 선떼붙이기공
### 5-16-1. 단끊기
(인/100m당)
| 공정별 | 보통인부(인) | | | 비고 |
|---|---|---|---|---|
| | 보통토사 | 경질ㆍ고사점토 및 자갈섞인 점토 | 호박돌 섞인 토사 | |
| 절취 | 2.4 | 3.3 | 5.4 | |
| 수평잡기 및 단정리 | 0.34 | 0.34 | 0.34 | |
| 잡석 및 뿌리 정리 | 0.36 | 0.36 | 0.36 | |
| 절·성토면 고르기 | 1.17 | 1.17 | 1.17 | |
| 합계 | 2.03 | 2.09 | 2.23 | |
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~4열 「보통인부(인)」 · 1열·5열 머리~몸 1행 -->
<!-- 원문 그대로: 「합계」 줄이 위 네 줄의 합과 다름 -->
[주]
① 단폭은 50∼70㎝를 기준으로 한다.
② 단끊기 연장 = 대상지경사도에 따른 직고 ÷ 단끊기할 단의 직고 × 연장으로 한다.
③ 본품은 현지경사도 45∘이하를 기준하여 단절고0.5m, 계단폭, 발디딤폭 0.6m으로 절취량을 0.15㎥/m당 기준하여 산출한 품이다. 특수현장 여건 절취 품은 절취량을 산출하여 본품에 나누어 사용한다.
④ 수평잡기 및 단정리, 잡석 및 뿌리정리, 성토면고르기, 고르기품은 100m당 품이다.
⑤ 작업반장은 1인/보통인부 20인 가산한다.
⑥ 단끊기는 선떼붙이기공의 공정으로 선떼붙이기 공종에 포함하여 설계한다.
### 5-16-2. 선떼붙이기
(10m당)
| 공정별 | 단위 | 수량 | 비고 |
|---|---|---|---|
| 단끊기 | m | 1 | |
| 떼붙임 | ㎡ | 물량산출 | |
| 떼운반 | ㎡ | 물량산출 | |
| 떼 지게운반 | ㎡ | 물량산출 | |
[주]
① 떼붙임 물량은 급수별 선떼, 머리떼, 받침떼, 바닥떼의 전체 물량을 말한다.
② 비탈에 따른 m당 떼사용 매수표는 사방기술교본 기준표와 실측에 의해 산출한다.
③ 떼 운반은 200m까지는 조견표를 이용하고 그 이상은 건설품셈에 의한 지게운반을 적용하며, 소운반과 지게운반을 같은 거리에서 이중계상 하지 못한다.
④ 작업 시 떼의 밀착, 수평, 식재단 조성, 토사침하 방지 등의 기술을 위한 작업반장을 1인/보통인부 20인을 별도 계상한다.
@@ -0,0 +1,45 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-17. 조공
PDF쪽: 77
인쇄쪽: 65
시작표지: 5-17. 조공
끝표지: 5-18. 씨뿌리기(줄)
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-17. 조공
### 5-17-1. 떼조공
(100m당)
| 공정별 | 단위 | 수량 | 비고 |
|---|---|---|---|
| 단끊기 | m | 1 | |
| 줄떼심기 | ㎡ | 물량산출 | |
| 떼운반 | ㎡ | 물량산출 | |
| 떼 지게운반 | ㎡ | 물량산출 | |
[주]
① 떼심기 물량은 반떼(10㎝ × 10㎝ × 5㎝)물량을 말한다.
② 단끊기 물량은 실측에 의하여 산출하고 직고는 0.7∼1.0m 이내로 하며 단끊기 품셈을 적용한다.
### 5-17-2. 돌조공
(m당)
| 공정별 | 단위 | 수량 | 비고 |
|---|---|---|---|
| 단끊기 | m | 1 | 단끊기 품셈 적용 |
| 돌쌓기 | ㎡ | 물량산출 | 막돌을 채취하여 사용 |
| 돌운반 | ㎡ | 물량산출 | 조견표에 의함 |
[주]
① 돌쌓기의 기울기는 1:0.2∼0.3, 계단의 나비는 50㎝, 돌쌓기의 높이는 50㎝ 이내로 한다.
② 단끊기 물량은 실측에 의하여 산출하고 직고는 1.0∼1.3m 이내로 하며, 단끊기 품셈을 적용한다.
@@ -0,0 +1,38 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-18. 씨뿌리기(줄)
PDF쪽: 77~78
인쇄쪽: 65~66
시작표지: 5-18. 씨뿌리기(줄)
끝표지: 5-19. 표토 절취 및 정지
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-18. 씨뿌리기(줄)
(m당)
| 공정별 | 단위 | 수량 | 비고 |
|---|---|---|---|
| 종자 | g | 2 | |
| 비료 | g | 29.3 | |
| 비토 | ㎥ | 0.002 | |
| 객토 | ㎥ | 0.0004 | |
| 골파기 | ㎥ | 0.0045 | |
| 씨덮기 | ㎥ | 0.002 | |
| 특별인부 | 인 | 0.00055 | |
| 보통인부 | 인 | 0.00838 | |
[주]
① 파종구는 깊이 10㎝, 너비 10㎝ 내외로 한다.
② 비료와 비토의 운반은 조견표에 의한다.
③ 골파기는 토질에 따른 인력 절취품을 적용한다.
④ 점파종을 할 때는 파종 구덩이를 ㏊당 40,000개를 기준으로 한다.
⑤ 씨뿌리기는 ㏊당 9,000m를 기준으로 실시한다.
⑥ 본 품에는 비토만들기, 골파기와 씨덮기 품이 포함되어 있다.
⑦ 객토는 현지여건에 따라 별도 적용할 수 있다.
@@ -0,0 +1,46 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-19. 표토 절취 및 정지
PDF쪽: 78
인쇄쪽: 66
시작표지: 5-19. 표토 절취 및 정지
끝표지: 5-20. 표토관리
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-19. 표토 절취 및 정지
### 5-19-1. 표토절취 및 모으기
(㎡당)
| 구분 | 직종 | 취급심도(㎝) | | | | |
|---|---|---|---|---|---|---|
| | | 6 | 9 | 12 | 15 | 18 |
| 표토절취 | 보통인부 | 0.20 | 0.17 | 0.16 | 0.15 | 0.14 |
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 3~7열 「취급심도(㎝)」 · 1열·2열 머리~몸 1행 -->
[주]
① 본 품은 식생복원용 소재로 활용 가능한 사질양토의 토질에 적용한다.
② 표토의 절취는 유기물층이나 부식층 등 식생의 도입에 긍정적인 영향을 줄 수 있는 대상지에서 시행하며, 무양토는 그 대상으로 적합하지 않다.
③ 절취 대상 지역의 잡관목 및 그루터기 등의 제거가 필요한 경우 별도로 계상한다.
### 5-19-2. 표토펴기 및 고르기
(인)
| 구분 | 직종 | 표토두께(㎝) | | 비고 |
|---|---|---|---|---|
| | | 15 | 30 | |
| ㎥ 당 | 보통인부 | 0.14 | 0.11 | |
| 100㎡ | | 2.14 | 3.33 | |
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 3~4열 「표토두께(㎝)」 · 1열·2열·5열 머리~몸 1행 -->
<!-- 병합: 2열 2~3행 「보통인부」 -->
[주]
본 품은 식재면 고르기를 포함한다.
@@ -0,0 +1,29 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-20. 표토관리
PDF쪽: 78
인쇄쪽: 66
시작표지: 5-20. 표토관리
끝표지: 5-21. 표토이식
상태: 검증대기
작성: sub_laptop_2
검증:
원천: 글자층
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
확정지문:
---
## 5-20. 표토관리
(㎡당)
| 공정 | 구분 | 공정량 | 비고 |
|---|---|---|---|
| 표토채취 | 보통인부 | 0.2인 | |
| | 굴착기(무한궤도, 0.7㎥) | 0.1시간 | |
| 표토붙이기 | 보통인부 | 0.2인 | |
| | 굴착기(무한궤도, 0.7㎥) | 0.1시간 | |
<!-- 병합: 1열 1~2행 「표토채취」 · 1열 3~4행 「표토붙이기」 -->
[주]
본 품은 벌목 후 장비ㆍ인력조합에 의한 표토의 선정, 장비에 의한 채취, 인력에 의한 뿌리다듬기, 이동, 터파기, 붙이기, 잔토정리 및 뒷정리 등이 포함되어 있다.
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-21. 표토이식
PDF쪽: 79
인쇄쪽: 67
시작표지: 5-21. 표토이식
끝표지: 5-22. 평떼
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-22. 평떼
PDF쪽: 79~80
인쇄쪽: 67~68
시작표지: 5-22. 평떼
끝표지: 5-23. 줄떼
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-23. 줄떼
PDF쪽: 80
인쇄쪽: 68
시작표지: 5-23. 줄떼
끝표지: 5-24. 씨앗뿜어붙이기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-24. 씨앗뿜어붙이기
PDF쪽: 81~83
인쇄쪽: 69~71
시작표지: 5-24. 씨앗뿜어붙이기
끝표지: 5-25. 거적덮기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-25. 거적덮기
PDF쪽: 83
인쇄쪽: 71
시작표지: 5-25. 거적덮기
끝표지: 5-26. 흙갈이
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-26. 흙갈이
PDF쪽: 83~84
인쇄쪽: 71~72
시작표지: 5-26. 흙갈이
끝표지: 5-27. 식재면 관리
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-27. 식재면 관리
PDF쪽: 84
인쇄쪽: 72
시작표지: 5-27. 식재면 관리
끝표지: 5-28. 비탈덮기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-28. 비탈덮기
PDF쪽: 84~85
인쇄쪽: 72~73
시작표지: 5-28. 비탈덮기
끝표지: 5-29. 복사이식
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-29. 복사이식
PDF쪽: 85
인쇄쪽: 73
시작표지: 5-29. 복사이식
끝표지: 5-30. 표시봉설치
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-30. 표시봉설치
PDF쪽: 85
인쇄쪽: 73
시작표지: 5-30. 표시봉설치
끝표지: 5-31. 지주목설치
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-31. 지주목설치
PDF쪽: 86
인쇄쪽: 74
시작표지: 5-31. 지주목설치
끝표지: 5-32. 대절작업
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-32. 대절작업
PDF쪽: 86
인쇄쪽: 74
시작표지: 5-32. 대절작업
끝표지: 5-33. 묘목 가식작업
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-33. 묘목 가식작업
PDF쪽: 86
인쇄쪽: 74
시작표지: 5-33. 묘목 가식작업
끝표지: 5-34. 묘목 소운반
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 5-34. 묘목 소운반
PDF쪽: 86~88
인쇄쪽: 74~76
시작표지: 5-34. 묘목 소운반
끝표지: 제6장 생육보조
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 제6장 생육보조
PDF쪽: 89
인쇄쪽: 77
시작표지: 제6장 생육보조
끝표지: 6-1. 비료주기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-1. 비료주기
PDF쪽: 89
인쇄쪽: 77
시작표지: 6-1. 비료주기
끝표지: 6-2. 풀베기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-2. 풀베기
PDF쪽: 89~90
인쇄쪽: 77~78
시작표지: 6-2. 풀베기
끝표지: 6-3. 맹아제거
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-3. 맹아제거
PDF쪽: 91
인쇄쪽: 79
시작표지: 6-3. 맹아제거
끝표지: 6-4. 덩굴제거
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-4. 덩굴제거
PDF쪽: 91~93
인쇄쪽: 79~81
시작표지: 6-4. 덩굴제거
끝표지: 6-5. 어린나무 가꾸기
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-5. 어린나무 가꾸기
PDF쪽: 94
인쇄쪽: 82
시작표지: 6-5. 어린나무 가꾸기
끝표지: 6-6. 가지치기 및 수형교정
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-6. 가지치기 및 수형교정
PDF쪽: 95
인쇄쪽: 83
시작표지: 6-6. 가지치기 및 수형교정
끝표지: 6-7. 토양개량 및 치환
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-7. 토양개량 및 치환
PDF쪽: 95~96
인쇄쪽: 83~84
시작표지: 6-7. 토양개량 및 치환
끝표지: 6-8. 목재칩 포설
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 6-8. 목재칩 포설
PDF쪽: 96
인쇄쪽: 84
시작표지: 6-8. 목재칩 포설
끝표지: 제7장 집재 및 운재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 제7장 집재 및 운재
PDF쪽: 97
인쇄쪽: 85
시작표지: 제7장 집재 및 운재
끝표지: 7-1. 인력집재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-1. 인력집재
PDF쪽: 97~98
인쇄쪽: 85~86
시작표지: 7-1. 인력집재
끝표지: 7-2. 수라집재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-2. 수라집재
PDF쪽: 98
인쇄쪽: 86
시작표지: 7-2. 수라집재
끝표지: 7-3. 아키야윈치(임업용 윈치) 집재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-3. 아키야윈치(임업용 윈치) 집재
PDF쪽: 99
인쇄쪽: 87
시작표지: 7-3. 아키야윈치(임업용 윈치) 집재
끝표지: 7-4. 소형가선 집재(2드럼 윈치)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-4. 소형가선 집재(2드럼 윈치)
PDF쪽: 99~100
인쇄쪽: 87~88
시작표지: 7-4. 소형가선 집재(2드럼 윈치)
끝표지: 7-5. 트랙터부착형 집재(지면끌기)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-5. 트랙터부착형 집재(지면끌기)
PDF쪽: 100~101
인쇄쪽: 88~89
시작표지: 7-5. 트랙터부착형 집재(지면끌기)
끝표지: 7-6. 스윙야더 집재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-6. 스윙야더 집재
PDF쪽: 101
인쇄쪽: 89
시작표지: 7-6. 스윙야더 집재
끝표지: 7-7. HAM200 집재(춘천․스마트)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-7. HAM200 집재(춘천․스마트)
PDF쪽: 102~103
인쇄쪽: 90~91
시작표지: 7-7. HAM200 집재(춘천․스마트)
끝표지: 7-8. 타워야더(RME 300T)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-8. 타워야더(RME 300T)
PDF쪽: 103~104
인쇄쪽: 91~92
시작표지: 7-8. 타워야더(RME 300T)
끝표지: 7-9. 부착형 타워(K-301, HAM300) 집재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-9. 부착형 타워(K-301, HAM300) 집재
PDF쪽: 104~105
인쇄쪽: 92~93
시작표지: 7-9. 부착형 타워(K-301, HAM300) 집재
끝표지: 7-10. 굴착기 우드그랩 산지집재-병해충방제
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-10. 굴착기 우드그랩 산지집재-병해충방제
PDF쪽: 106
인쇄쪽: 94
시작표지: 7-10. 굴착기 우드그랩 산지집재-병해충방제
끝표지: 7-11. 동력상하차기(우드그래플) 집재-수확
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-11. 동력상하차기(우드그래플) 집재-수확
PDF쪽: 106~107
인쇄쪽: 94~95
시작표지: 7-11. 동력상하차기(우드그래플) 집재-수확
끝표지: 7-12. 동력상하차기(우드그래플) 집적
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-12. 동력상하차기(우드그래플) 집적
PDF쪽: 107~108
인쇄쪽: 95~96
시작표지: 7-12. 동력상하차기(우드그래플) 집적
끝표지: 7-13. 검척
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-13. 검척
PDF쪽: 108
인쇄쪽: 96
시작표지: 7-13. 검척
끝표지: 7-14. 원목 운반-수확
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-14. 원목 운반-수확
PDF쪽: 109
인쇄쪽: 97
시작표지: 7-14. 원목 운반-수확
끝표지: 7-15. 초소형 포워더 운재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-15. 초소형 포워더 운재
PDF쪽: 109~110
인쇄쪽: 97~98
시작표지: 7-15. 초소형 포워더 운재
끝표지: 7-16. 소형 포워더 운재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-16. 소형 포워더 운재
PDF쪽: 110
인쇄쪽: 98
시작표지: 7-16. 소형 포워더 운재
끝표지: 7-17. 소형트럭 운재
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-17. 소형트럭 운재
PDF쪽: 110
인쇄쪽: 98
시작표지: 7-17. 소형트럭 운재
끝표지: 7-18. 지조운반(포워더 활용)
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---
@@ -0,0 +1,14 @@
---
문서: 산림사업 표준품셈(산림청고시 제2025-82호)
절: 7-18. 지조운반(포워더 활용)
PDF쪽: 111
인쇄쪽: 99
시작표지: 7-18. 지조운반(포워더 활용)
끝표지: 제8장 방제
상태: 대기
작성:
검증:
원천: 글자층
기계검사:
확정지문:
---

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