Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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`(브레인 조건).
|
||||
"""
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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.3∼0.7m 이하 | 0.8∼1.1m 이하 | 1.2∼1.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.3∼0.7m 이하 | 0.8∼1.1m 이하 | 1.2∼1.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) | 인력구분 |
|
||||
|---|---|---|---|
|
||||
| 지면긁기작업 | 폭 30~40cm × 열간거리 2m<br>(전면적의 15~20%) | 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.0~5.0 | |
|
||||
| 평떼 | 5.0~7.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.3~0.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) 집재
|
||||
상태: 대기
|
||||
작성:
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
확정지문:
|
||||
---
|
||||
+14
@@ -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
Reference in New Issue
Block a user