diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index b4da3fc7..1fffe9fd 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -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[]; total: number; + /** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */ + revision?: number; } export interface RowsQuery { @@ -65,17 +65,34 @@ export function fetchBaseKinds(): Promise { 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(path: string, init: RequestInit = {}): Promise { 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 = (method: string, path: string, body: unknown): Promise => + request(path, { method, body: JSON.stringify(body) }); + const pageParams = (query: RowsQuery): Record => ({ page: String(query.page), size: String(query.size), @@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise< return request(`/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 }[]; + deleted: string[]; } -/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */ -export function fetchOverrides( - query: Pick, -): 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 { + return send("PUT", `/master-data/base-prices/${kind}`, body); } -/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ -export function saveBasePrice( - kind: BasePriceKind, - rowId: string, - values: Record, -): Promise> { - return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, { - method: "PUT", - body: JSON.stringify({ values }), +/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */ +export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise { + 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>; +} + +export interface WorkItemListQuery { + axis: WorkItemAxis; + status: WorkItemStatus | ""; + q: string; + page: number; + size: number; +} + +export function fetchWorkItems(query: WorkItemListQuery): Promise { + 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(`/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 { + return request(workItemPath(key)); +} + +/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */ +export function saveWorkItem(key: string, body: WorkItemSave): Promise { + return send("PUT", workItemPath(key), body); +} + +/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */ +export function resetWorkItem(key: string): Promise { + return send("POST", `${workItemPath(key)}/reset`, {}); } export async function fetchMasterTree(): Promise { diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 9a3d7342..0ccb5576 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -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)) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py index 350a84c8..e0308862 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py @@ -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: diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index a89bdf7f..a0f55798 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -4,7 +4,7 @@ 태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래 안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 · 구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임) -⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음. +⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 다른 표가 줄을 못 찾음. ⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건). """ diff --git a/Z01_MasterData/Z01_MasterData_Overrides.py b/Z01_MasterData/Z01_MasterData_Overrides.py deleted file mode 100644 index 9cdc5a8e..00000000 --- a/Z01_MasterData/Z01_MasterData_Overrides.py +++ /dev/null @@ -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 diff --git a/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py b/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py new file mode 100644 index 00000000..a3013799 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py @@ -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 diff --git a/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py b/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py new file mode 100644 index 00000000..11f0658a --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py @@ -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], + ) diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index 294d1337..d47be921 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -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}) diff --git a/Z01_MasterData/Z01_MasterData_Router_WorkItems.py b/Z01_MasterData/Z01_MasterData_Router_WorkItems.py new file mode 100644 index 00000000..dfbac857 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Router_WorkItems.py @@ -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) diff --git a/Z01_MasterData/Z01_MasterData_UI_Draft.ts b/Z01_MasterData/Z01_MasterData_UI_Draft.ts new file mode 100644 index 00000000..60412835 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Draft.ts @@ -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(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; +} +interface BaseBody { + edits: Edit[]; + added: AddedRow[]; + deleted: string[]; +} + +export class BaseDraft { + readonly edits = new Map(); + added: AddedRow[] = []; + readonly deleted = new Set(); + onChange: () => void = () => undefined; + + constructor( + readonly scope: string, + readonly revision: number, + ) { + const saved = readDraft(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 = 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 | "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; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index 021e9198..8cfa43bf 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -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 { @@ -41,12 +42,32 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { } 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), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 7309885c..1735ed17 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -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; +type LocaleKey = Parameters[0]; export interface RowsSource { title: string; key: string; - /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ + /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */ searchable?: boolean; load: (query: RowsQuery) => Promise; - /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ - save?: (rowId: string, values: Row) => Promise; + /** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */ + edit?: { + scope: string; + save: (body: BaseSave) => Promise; + reset: (rowKeys: string[]) => Promise; + }; } export interface RowsView { @@ -58,36 +72,15 @@ export interface RowsView { } /** 사전 문구의 {value} 자리에 값을 넣음. */ -function fill(key: Parameters[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 { - 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 { - 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 { + 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 { + 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(); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 0e958c6d..3a3f6860 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -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[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, -): Promise { - const { items, total } = await fetchOverrides(query); - const column = (key: string, label: Parameters[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", diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index b62321c4..24640741 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -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); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts b/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts new file mode 100644 index 00000000..51ee3e9f --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts @@ -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[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 = { + 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 { + 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 { + 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(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 { + 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 { + 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 = ( + 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 }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts new file mode 100644 index 00000000..121ae081 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts @@ -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[0]; + +const KIND_LABEL: Record = { + 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; +const FINDABLE: Record = { + 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)[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 { + 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); +} diff --git a/Z01_MasterData/Z01_MasterData_WorkItems.py b/Z01_MasterData/Z01_MasterData_WorkItems.py index 23c8d85b..c62360a5 100644 --- a/Z01_MasterData/Z01_MasterData_WorkItems.py +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -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], + } diff --git a/main.py b/main.py index 85f2344a..3d48f738 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py new file mode 100644 index 00000000..bf75c103 --- /dev/null +++ b/resources/knowledge/original/_pipeline/pum_md_tool.py @@ -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 ① 수 대조 ② 글자 대조 → 머리 「기계검사」 칸 + 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("
", " ") + 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:])) diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/0-00_장머리.md new file mode 100644 index 00000000..ffece5d0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제1장 적용기준 +PDF쪽: 13 +인쇄쪽: 1 +시작표지: 제1장 적용기준 +끝표지: 1-1. 일반사항 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-01_일반사항.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-01_일반사항.md new file mode 100644 index 00000000..35afe302 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-01_일반사항.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-1. 일반사항 +PDF쪽: 13~14 +인쇄쪽: 1~2 +시작표지: 1-1. 일반사항 +끝표지: 1-2. 설계 및 수량 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md new file mode 100644 index 00000000..bef114de --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-2. 설계 및 수량 +PDF쪽: 14~27 +인쇄쪽: 2~15 +시작표지: 1-2. 설계 및 수량 +끝표지: 1-3. 재료 및 노임의 할증 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-03_재료_및_노임의_할증.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-03_재료_및_노임의_할증.md new file mode 100644 index 00000000..f3e5211a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-03_재료_및_노임의_할증.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-3. 재료 및 노임의 할증 +PDF쪽: 27~29 +인쇄쪽: 15~17 +시작표지: 1-3. 재료 및 노임의 할증 +끝표지: 1-4. 품의 할인․할증 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-04_품의_할인․할증.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-04_품의_할인․할증.md new file mode 100644 index 00000000..6911c0e5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-04_품의_할인․할증.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-4. 품의 할인․할증 +PDF쪽: 29~36 +인쇄쪽: 17~24 +시작표지: 1-4. 품의 할인․할증 +끝표지: 1-5. 목재 원목시가 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-05_목재_원목시가.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-05_목재_원목시가.md new file mode 100644 index 00000000..f08c4c2d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-05_목재_원목시가.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-5. 목재 원목시가 +PDF쪽: 36 +인쇄쪽: 24 +시작표지: 1-5. 목재 원목시가 +끝표지: 1-6. 원가 작성기준 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-06_원가_작성기준.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-06_원가_작성기준.md new file mode 100644 index 00000000..30d49cc3 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-06_원가_작성기준.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-6. 원가 작성기준 +PDF쪽: 36~37 +인쇄쪽: 24~25 +시작표지: 1-6. 원가 작성기준 +끝표지: 1-7. 기타사항 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-07_기타사항.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-07_기타사항.md new file mode 100644 index 00000000..b7e9954d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-07_기타사항.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 1-7. 기타사항 +PDF쪽: 38~39 +인쇄쪽: 26~27 +시작표지: 1-7. 기타사항 +끝표지: 제2장 소요재료 및 기계손료 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/0-00_장머리.md new file mode 100644 index 00000000..2ef33259 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제2장 소요재료 및 기계손료 +PDF쪽: 40 +인쇄쪽: 28 +시작표지: 제2장 소요재료 및 기계손료 +끝표지: 2-1. 소요재료 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-01_소요재료.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-01_소요재료.md new file mode 100644 index 00000000..cf91eaf6 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-01_소요재료.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 2-1. 소요재료 +PDF쪽: 40~47 +인쇄쪽: 28~35 +시작표지: 2-1. 소요재료 +끝표지: 2-2. 기계손료 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-02_기계손료.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-02_기계손료.md new file mode 100644 index 00000000..86b79bab --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/2-02_기계손료.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 2-2. 기계손료 +PDF쪽: 48~51 +인쇄쪽: 36~39 +시작표지: 2-2. 기계손료 +끝표지: 제3장 작업장 관리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/0-00_장머리.md new file mode 100644 index 00000000..54879a15 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제3장 작업장 관리 +PDF쪽: 52 +인쇄쪽: 40 +시작표지: 제3장 작업장 관리 +끝표지: 3-1. 경계표시 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-01_경계표시.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-01_경계표시.md new file mode 100644 index 00000000..e9625b2e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-01_경계표시.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-1. 경계표시 +PDF쪽: 52 +인쇄쪽: 40 +시작표지: 3-1. 경계표시 +끝표지: 3-2. 작업로 선정 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-02_작업로_선정.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-02_작업로_선정.md new file mode 100644 index 00000000..00684cab --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-02_작업로_선정.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-2. 작업로 선정 +PDF쪽: 52 +인쇄쪽: 40 +시작표지: 3-2. 작업로 선정 +끝표지: 3-3. 작업로 설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-03_작업로_설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-03_작업로_설치.md new file mode 100644 index 00000000..b87ad18a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-03_작업로_설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-3. 작업로 설치 +PDF쪽: 52~53 +인쇄쪽: 40~41 +시작표지: 3-3. 작업로 설치 +끝표지: 3-4. 임산물 운반로 신설 및 보수·복구 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-04_임산물_운반로_신설_및_보수·복구.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-04_임산물_운반로_신설_및_보수·복구.md new file mode 100644 index 00000000..1c7681a2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-04_임산물_운반로_신설_및_보수·복구.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-4. 임산물 운반로 신설 및 보수·복구 +PDF쪽: 53~55 +인쇄쪽: 41~43 +시작표지: 3-4. 임산물 운반로 신설 및 보수·복구 +끝표지: 3-5. 예정지정리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-05_예정지정리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-05_예정지정리.md new file mode 100644 index 00000000..d489380f --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-05_예정지정리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-5. 예정지정리 +PDF쪽: 56~57 +인쇄쪽: 44~45 +시작표지: 3-5. 예정지정리 +끝표지: 3-6. 산물 임내정리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-06_산물_임내정리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-06_산물_임내정리.md new file mode 100644 index 00000000..36f8cf59 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-06_산물_임내정리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-6. 산물 임내정리 +PDF쪽: 57 +인쇄쪽: 45 +시작표지: 3-6. 산물 임내정리 +끝표지: 3-7. 재해산물 수집 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-07_재해산물_수집.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-07_재해산물_수집.md new file mode 100644 index 00000000..e9fdeb60 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-07_재해산물_수집.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-7. 재해산물 수집 +PDF쪽: 58 +인쇄쪽: 46 +시작표지: 3-7. 재해산물 수집 +끝표지: 3-8. 드론 영상 촬영 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-08_드론_영상_촬영.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-08_드론_영상_촬영.md new file mode 100644 index 00000000..5dc2d430 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제03장_작업장_관리/3-08_드론_영상_촬영.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 3-8. 드론 영상 촬영 +PDF쪽: 58 +인쇄쪽: 46 +시작표지: 3-8. 드론 영상 촬영 +끝표지: 제4장 나무베기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-02_뿌리돌림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-02_뿌리돌림.md new file mode 100644 index 00000000..6faaa209 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-02_뿌리돌림.md @@ -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 | + + +[주] +① 뿌리돌림은 수목 이식 전에 뿌리 분 밖으로 돌출된 뿌리를 깨끗이 절단하여 주근 가까운 곳의 측근과 잔뿌리의 발달을 촉진시키는 작업이다. +② 분은 근원직경의 4∼5배로 한다. +③ 뿌리 절단 부위의 보호를 위한 재료비는 별도 계상한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-03_식재작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-03_식재작업.md new file mode 100644 index 00000000..8cef0a2b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-03_식재작업.md @@ -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%
보통인부 70% | +| 중묘식재 | 중묘 | 4.00 | | +| 대묘식재 | 대묘 | 5.00 | | + + +[주] +① 본 품은 구덩이 파기, 묘목심기와 뒷정리를 포함한다. 묘목 운반비는 별도로 적용한다. +② 보식의 경우에는 나무식재 품의 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(용) | | | +| ※ 위 표에서 ‘노’는 ‘노지묘’이고, ‘용’은 ‘용기묘’이다.
※ 그 밖의 수종은 수종별 간장(최소 간장을 말한다) 및 근원경을 기준으로 아래와 같이 적용하되, 활엽수 삽목묘ㆍ접목묘 대묘는 이를 중묘로 본다. | | | | + + +| 구분 | 침엽수 | 활엽수 | +|---|---|---| +| 소묘 | 간장 20cm미만 | 간장 30cm미만 | +| 중묘 | 간장 20cm이상 40cm미만 또는
간장 40cm이상이고 근원경 8mm미만 | 간장 30cm이상 60cm미만 또는
간장 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.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.5m를 초과할 때는 나무높이에 비례하여 할증할 수 있다. +④ 나무높이보다 수관폭이 더 클 때에는 그 수관폭을 나무높이로 본다. +⑤ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다. +⑥ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다. +⑦ 암반식재, 부적기식재 등 특수식재는 품을 별도 계상할 수 있다. +⑧ 군식은 일반적으로 아래의 식재밀도 이상인 경우이다. + + +### 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 | +| 비고 | - 지주목을 세우지 않을 때는 다음의 요율을 감한다. | | | | | + + +| 인력시공시 | 기계시공시 | +|---|---| +| 인력품의 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 | +| 비고 | - 지주목을 세우지 않을 때는 다음의 요율을 감한다. | | | | + + +| 인력시공시 | 기계시공시 | +|---|---| +| 인력품의 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 | + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-04_파종조림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-04_파종조림.md new file mode 100644 index 00000000..582d87d8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-04_파종조림.md @@ -0,0 +1,33 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-4. 파종조림 +PDF쪽: 71 +인쇄쪽: 59 +시작표지: 5-4. 파종조림 +끝표지: 5-5. 천연하종갱신 +상태: 확정 +작성: sub_laptop_3 +검증: sub_laptop_2 +원천: 글자층 +기계검사: +확정지문: +--- + +## 5-4. 파종조림 + +(인/ha당) + +| 구분 | 내용 | 소요인력
(인/ha) | 인력구분 | +|---|---|---|---| +| 파종상 만들기 | 40cm×40cm×5,000개 | 6 | 보통인부 | +| | 80cm×80cm×5,000개 | 11 | | +| 파종상에 점파 | 5,000판×3립 | 7.2 | | +| 파종상 없이 | 조파 | 9.2 | | +| | 점파 | 7.2 | | + + +[주] +① 파종상 만들기란, 종자파종 할 지역의 지피물을 제거하고 토양을 괭이나 삽으로 경운하여 돌이나 잡초목의 뿌리 등을 제거한 후 10㎝ 높이로 상을 만드는 작업을 말한다. +② 종자파종 품에는 파종 후 종자지름의 2∼3배 흙을 복토하고 망사, 플라스틱 원통 또는 종이컵 등으로 방조물을 설치하는 작업을 포함한다. +③ 예정지정리가 필요한 경우에는 "3-5 예정지 정리작업"을 적용한다. +④ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6), 토양상태(1-4-10)를 반영한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-05_천연하종갱신.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-05_천연하종갱신.md new file mode 100644 index 00000000..419bd32c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-05_천연하종갱신.md @@ -0,0 +1,29 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-5. 천연하종갱신 +PDF쪽: 71 +인쇄쪽: 59 +시작표지: 5-5. 천연하종갱신 +끝표지: 5-6. 움싹갱신 +상태: 확정 +작성: sub_laptop_3 +검증: sub_laptop_2 +원천: 글자층 +기계검사: +확정지문: +--- + +## 5-5. 천연하종갱신 + +(인/ha당) + +| 구분 | 내용 | 소요인력
(인/ha) | 인력구분 | +|---|---|---|---| +| 지면긁기작업 | 폭 30~40cm × 열간거리 2m
(전면적의 15~20%) | 8 | 보통인부 | +| | 폭 80cm × 열간거리 2m
(전면적의 40%) | 15 | | + + +[주] +① 지면긁기작업이란, 떨어진 종자가 잘 발아할 수 있도록 등고선 방향으로 지피물을 제거하고 토양 광물층이 노출되도록 하는 작업을 말하며, 모수에서 종자가 떨어지기 전에 실시한다. +② 예정지정리가 필요한 경우에는 "3-5. 예정지 정리작업"을 적용한다. +③ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6), 토양상태(1-4-10)를 반영한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-06_움싹갱신.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-06_움싹갱신.md new file mode 100644 index 00000000..69b0716d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-06_움싹갱신.md @@ -0,0 +1,28 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-6. 움싹갱신 +PDF쪽: 71 +인쇄쪽: 59 +시작표지: 5-6. 움싹갱신 +끝표지: 5-7. 생태보완조림 +상태: 확정 +작성: sub_laptop_3 +검증: sub_laptop_2 +원천: 글자층 +기계검사: +확정지문: +--- + +## 5-6. 움싹갱신 + +(인/ha당) + +| 구분 | 내용 | 소요인력
(인/ha) | 인력구분 | +|---|---|---|---| +| 맹아근주 정리작업 | 맹아근주 900본 기준 | 4.95 | 특별인부 50%
보통인부 50% | + +[주] +① 맹아근주 정리작업이란, 벌채면을 평활하고 약간 기울게 하여 물이 고이지 않도록 잘라주는 작업을 말한다. +② 본 품에는 움싹이 잘 발생할 수 있도록 벌근 주위를 정리하는 작업이 포함한다. +③ 예정지정리가 필요한 경우에는 "3-5. 예정지 정리작업"을 적용한다. +④ 할인․할증은 산지경사(1-4-5), 작업장까지 이동거리(1-4-6)를 반영한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-07_생태보완조림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-07_생태보완조림.md new file mode 100644 index 00000000..e55fab68 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-07_생태보완조림.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-7. 생태보완조림 +PDF쪽: 72 +인쇄쪽: 60 +시작표지: 5-7. 생태보완조림 +끝표지: 5-8. 큰나무 공익조림 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-08_큰나무_공익조림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-08_큰나무_공익조림.md new file mode 100644 index 00000000..f7c43b97 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-08_큰나무_공익조림.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-8. 큰나무 공익조림 +PDF쪽: 72 +인쇄쪽: 60 +시작표지: 5-8. 큰나무 공익조림 +끝표지: 5-9. 해안조림 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-09_해안조림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-09_해안조림.md new file mode 100644 index 00000000..d0f8c5cd --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-09_해안조림.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-9. 해안조림 +PDF쪽: 72~73 +인쇄쪽: 60~61 +시작표지: 5-9. 해안조림 +끝표지: 5-10. 사방조림 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-10_사방조림.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-10_사방조림.md new file mode 100644 index 00000000..3712e125 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-10_사방조림.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-10. 사방조림 +PDF쪽: 73 +인쇄쪽: 61 +시작표지: 5-10. 사방조림 +끝표지: 5-11. 사초심기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md new file mode 100644 index 00000000..45e4e755 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md @@ -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 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md new file mode 100644 index 00000000..a8dca10d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md @@ -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장에 대하여 실시한다). +④ 성토면 시공시 흙다지기가 필요할 때 건설품셈의 품을 적용할 수 있다. +⑤ 떼 값 및 운반은 별도 계상한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md new file mode 100644 index 00000000..ed50f117 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md @@ -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㎝ 폭의 떼를 골파기 후 심는 것으로 토질에 따른 골파기품, 떼 값은 별도 계상한다. +⑤ 떼 자르기품은 포함되어 있다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md new file mode 100644 index 00000000..18bba5ad --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md @@ -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인을 계상한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md new file mode 100644 index 00000000..28ce0e4d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md @@ -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로 한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md new file mode 100644 index 00000000..a139b60e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md @@ -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 | | + + + +[주] +① 단폭은 50∼70㎝를 기준으로 한다. +② 단끊기 연장 = 대상지경사도에 따른 직고 ÷ 단끊기할 단의 직고 × 연장으로 한다. +③ 본품은 현지경사도 45∘이하를 기준하여 단절고0.5m, 계단폭, 발디딤폭 0.6m으로 절취량을 0.15㎥/m당 기준하여 산출한 품이다. 특수현장 여건 절취 품은 절취량을 산출하여 본품에 나누어 사용한다. +④ 수평잡기 및 단정리, 잡석 및 뿌리정리, 성토면고르기, 고르기품은 100m당 품이다. +⑤ 작업반장은 1인/보통인부 20인 가산한다. +⑥ 단끊기는 선떼붙이기공의 공정으로 선떼붙이기 공종에 포함하여 설계한다. + +### 5-16-2. 선떼붙이기 + +(10m당) + +| 공정별 | 단위 | 수량 | 비고 | +|---|---|---|---| +| 단끊기 | m | 1 | | +| 떼붙임 | ㎡ | 물량산출 | | +| 떼운반 | ㎡ | 물량산출 | | +| 떼 지게운반 | ㎡ | 물량산출 | | + +[주] +① 떼붙임 물량은 급수별 선떼, 머리떼, 받침떼, 바닥떼의 전체 물량을 말한다. +② 비탈에 따른 m당 떼사용 매수표는 사방기술교본 기준표와 실측에 의해 산출한다. +③ 떼 운반은 200m까지는 조견표를 이용하고 그 이상은 건설품셈에 의한 지게운반을 적용하며, 소운반과 지게운반을 같은 거리에서 이중계상 하지 못한다. +④ 작업 시 떼의 밀착, 수평, 식재단 조성, 토사침하 방지 등의 기술을 위한 작업반장을 1인/보통인부 20인을 별도 계상한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md new file mode 100644 index 00000000..af03ac22 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md @@ -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 이내로 하며, 단끊기 품셈을 적용한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md new file mode 100644 index 00000000..9c4aaf9d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md @@ -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를 기준으로 실시한다. +⑥ 본 품에는 비토만들기, 골파기와 씨덮기 품이 포함되어 있다. +⑦ 객토는 현지여건에 따라 별도 적용할 수 있다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md new file mode 100644 index 00000000..458edc82 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md @@ -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 | + + +[주] +① 본 품은 식생복원용 소재로 활용 가능한 사질양토의 토질에 적용한다. +② 표토의 절취는 유기물층이나 부식층 등 식생의 도입에 긍정적인 영향을 줄 수 있는 대상지에서 시행하며, 무양토는 그 대상으로 적합하지 않다. +③ 절취 대상 지역의 잡관목 및 그루터기 등의 제거가 필요한 경우 별도로 계상한다. + +### 5-19-2. 표토펴기 및 고르기 + +(인) + +| 구분 | 직종 | 표토두께(㎝) | | 비고 | +|---|---|---|---|---| +| | | 15 | 30 | | +| ㎥ 당 | 보통인부 | 0.14 | 0.11 | | +| 100㎡ | | 2.14 | 3.33 | | + + + +[주] +본 품은 식재면 고르기를 포함한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md new file mode 100644 index 00000000..49fa1a01 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md @@ -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시간 | | + + +[주] +본 품은 벌목 후 장비ㆍ인력조합에 의한 표토의 선정, 장비에 의한 채취, 인력에 의한 뿌리다듬기, 이동, 터파기, 붙이기, 잔토정리 및 뒷정리 등이 포함되어 있다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md new file mode 100644 index 00000000..96cb2781 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-21. 표토이식 +PDF쪽: 79 +인쇄쪽: 67 +시작표지: 5-21. 표토이식 +끝표지: 5-22. 평떼 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md new file mode 100644 index 00000000..39647bbb --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-22. 평떼 +PDF쪽: 79~80 +인쇄쪽: 67~68 +시작표지: 5-22. 평떼 +끝표지: 5-23. 줄떼 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md new file mode 100644 index 00000000..7766c8bc --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-23. 줄떼 +PDF쪽: 80 +인쇄쪽: 68 +시작표지: 5-23. 줄떼 +끝표지: 5-24. 씨앗뿜어붙이기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md new file mode 100644 index 00000000..9db9e15e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-24. 씨앗뿜어붙이기 +PDF쪽: 81~83 +인쇄쪽: 69~71 +시작표지: 5-24. 씨앗뿜어붙이기 +끝표지: 5-25. 거적덮기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md new file mode 100644 index 00000000..5c4f0696 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-25. 거적덮기 +PDF쪽: 83 +인쇄쪽: 71 +시작표지: 5-25. 거적덮기 +끝표지: 5-26. 흙갈이 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md new file mode 100644 index 00000000..1c122293 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-26. 흙갈이 +PDF쪽: 83~84 +인쇄쪽: 71~72 +시작표지: 5-26. 흙갈이 +끝표지: 5-27. 식재면 관리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md new file mode 100644 index 00000000..c67bdf04 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-27. 식재면 관리 +PDF쪽: 84 +인쇄쪽: 72 +시작표지: 5-27. 식재면 관리 +끝표지: 5-28. 비탈덮기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md new file mode 100644 index 00000000..d4c55cd6 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-28. 비탈덮기 +PDF쪽: 84~85 +인쇄쪽: 72~73 +시작표지: 5-28. 비탈덮기 +끝표지: 5-29. 복사이식 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md new file mode 100644 index 00000000..9be96618 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-29. 복사이식 +PDF쪽: 85 +인쇄쪽: 73 +시작표지: 5-29. 복사이식 +끝표지: 5-30. 표시봉설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md new file mode 100644 index 00000000..3a615efd --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-30. 표시봉설치 +PDF쪽: 85 +인쇄쪽: 73 +시작표지: 5-30. 표시봉설치 +끝표지: 5-31. 지주목설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md new file mode 100644 index 00000000..79063b71 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-31. 지주목설치 +PDF쪽: 86 +인쇄쪽: 74 +시작표지: 5-31. 지주목설치 +끝표지: 5-32. 대절작업 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md new file mode 100644 index 00000000..9c9f36e1 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-32. 대절작업 +PDF쪽: 86 +인쇄쪽: 74 +시작표지: 5-32. 대절작업 +끝표지: 5-33. 묘목 가식작업 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md new file mode 100644 index 00000000..d05c01aa --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-33. 묘목 가식작업 +PDF쪽: 86 +인쇄쪽: 74 +시작표지: 5-33. 묘목 가식작업 +끝표지: 5-34. 묘목 소운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md new file mode 100644 index 00000000..8825ab7e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 5-34. 묘목 소운반 +PDF쪽: 86~88 +인쇄쪽: 74~76 +시작표지: 5-34. 묘목 소운반 +끝표지: 제6장 생육보조 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/0-00_장머리.md new file mode 100644 index 00000000..4beb01f0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제6장 생육보조 +PDF쪽: 89 +인쇄쪽: 77 +시작표지: 제6장 생육보조 +끝표지: 6-1. 비료주기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-01_비료주기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-01_비료주기.md new file mode 100644 index 00000000..5e67ae03 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-01_비료주기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-1. 비료주기 +PDF쪽: 89 +인쇄쪽: 77 +시작표지: 6-1. 비료주기 +끝표지: 6-2. 풀베기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-02_풀베기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-02_풀베기.md new file mode 100644 index 00000000..52cff3bd --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-02_풀베기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-2. 풀베기 +PDF쪽: 89~90 +인쇄쪽: 77~78 +시작표지: 6-2. 풀베기 +끝표지: 6-3. 맹아제거 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-03_맹아제거.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-03_맹아제거.md new file mode 100644 index 00000000..a6d1ddf2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-03_맹아제거.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-3. 맹아제거 +PDF쪽: 91 +인쇄쪽: 79 +시작표지: 6-3. 맹아제거 +끝표지: 6-4. 덩굴제거 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-04_덩굴제거.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-04_덩굴제거.md new file mode 100644 index 00000000..a6e6fe85 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-04_덩굴제거.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-4. 덩굴제거 +PDF쪽: 91~93 +인쇄쪽: 79~81 +시작표지: 6-4. 덩굴제거 +끝표지: 6-5. 어린나무 가꾸기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-05_어린나무_가꾸기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-05_어린나무_가꾸기.md new file mode 100644 index 00000000..6ee1baf7 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-05_어린나무_가꾸기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-5. 어린나무 가꾸기 +PDF쪽: 94 +인쇄쪽: 82 +시작표지: 6-5. 어린나무 가꾸기 +끝표지: 6-6. 가지치기 및 수형교정 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-06_가지치기_및_수형교정.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-06_가지치기_및_수형교정.md new file mode 100644 index 00000000..c67530c8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-06_가지치기_및_수형교정.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-6. 가지치기 및 수형교정 +PDF쪽: 95 +인쇄쪽: 83 +시작표지: 6-6. 가지치기 및 수형교정 +끝표지: 6-7. 토양개량 및 치환 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-07_토양개량_및_치환.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-07_토양개량_및_치환.md new file mode 100644 index 00000000..af8a32cf --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-07_토양개량_및_치환.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-7. 토양개량 및 치환 +PDF쪽: 95~96 +인쇄쪽: 83~84 +시작표지: 6-7. 토양개량 및 치환 +끝표지: 6-8. 목재칩 포설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-08_목재칩_포설.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-08_목재칩_포설.md new file mode 100644 index 00000000..32c50bb2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제06장_생육보조/6-08_목재칩_포설.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 6-8. 목재칩 포설 +PDF쪽: 96 +인쇄쪽: 84 +시작표지: 6-8. 목재칩 포설 +끝표지: 제7장 집재 및 운재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md new file mode 100644 index 00000000..a7f98cfc --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제7장 집재 및 운재 +PDF쪽: 97 +인쇄쪽: 85 +시작표지: 제7장 집재 및 운재 +끝표지: 7-1. 인력집재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md new file mode 100644 index 00000000..b940f37b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-1. 인력집재 +PDF쪽: 97~98 +인쇄쪽: 85~86 +시작표지: 7-1. 인력집재 +끝표지: 7-2. 수라집재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md new file mode 100644 index 00000000..638014e8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-2. 수라집재 +PDF쪽: 98 +인쇄쪽: 86 +시작표지: 7-2. 수라집재 +끝표지: 7-3. 아키야윈치(임업용 윈치) 집재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-03_아키야윈치(임업용_윈치)_집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-03_아키야윈치(임업용_윈치)_집재.md new file mode 100644 index 00000000..e859f2e0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-03_아키야윈치(임업용_윈치)_집재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-3. 아키야윈치(임업용 윈치) 집재 +PDF쪽: 99 +인쇄쪽: 87 +시작표지: 7-3. 아키야윈치(임업용 윈치) 집재 +끝표지: 7-4. 소형가선 집재(2드럼 윈치) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-04_소형가선_집재(2드럼_윈치).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-04_소형가선_집재(2드럼_윈치).md new file mode 100644 index 00000000..71e3b341 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-04_소형가선_집재(2드럼_윈치).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-4. 소형가선 집재(2드럼 윈치) +PDF쪽: 99~100 +인쇄쪽: 87~88 +시작표지: 7-4. 소형가선 집재(2드럼 윈치) +끝표지: 7-5. 트랙터부착형 집재(지면끌기) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-05_트랙터부착형_집재(지면끌기).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-05_트랙터부착형_집재(지면끌기).md new file mode 100644 index 00000000..6399c4e5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-05_트랙터부착형_집재(지면끌기).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-5. 트랙터부착형 집재(지면끌기) +PDF쪽: 100~101 +인쇄쪽: 88~89 +시작표지: 7-5. 트랙터부착형 집재(지면끌기) +끝표지: 7-6. 스윙야더 집재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-06_스윙야더_집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-06_스윙야더_집재.md new file mode 100644 index 00000000..1488f5ce --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-06_스윙야더_집재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-6. 스윙야더 집재 +PDF쪽: 101 +인쇄쪽: 89 +시작표지: 7-6. 스윙야더 집재 +끝표지: 7-7. HAM200 집재(춘천․스마트) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-07_HAM200_집재(춘천․스마트).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-07_HAM200_집재(춘천․스마트).md new file mode 100644 index 00000000..67a9714f --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-07_HAM200_집재(춘천․스마트).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-7. HAM200 집재(춘천․스마트) +PDF쪽: 102~103 +인쇄쪽: 90~91 +시작표지: 7-7. HAM200 집재(춘천․스마트) +끝표지: 7-8. 타워야더(RME 300T) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-08_타워야더(RME_300T).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-08_타워야더(RME_300T).md new file mode 100644 index 00000000..b800d836 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-08_타워야더(RME_300T).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-8. 타워야더(RME 300T) +PDF쪽: 103~104 +인쇄쪽: 91~92 +시작표지: 7-8. 타워야더(RME 300T) +끝표지: 7-9. 부착형 타워(K-301, HAM300) 집재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-09_부착형_타워(K-301,_HAM300)_집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-09_부착형_타워(K-301,_HAM300)_집재.md new file mode 100644 index 00000000..f1d2dde6 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-09_부착형_타워(K-301,_HAM300)_집재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-9. 부착형 타워(K-301, HAM300) 집재 +PDF쪽: 104~105 +인쇄쪽: 92~93 +시작표지: 7-9. 부착형 타워(K-301, HAM300) 집재 +끝표지: 7-10. 굴착기 우드그랩 산지집재-병해충방제 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-10_굴착기_우드그랩_산지집재-병해충방제.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-10_굴착기_우드그랩_산지집재-병해충방제.md new file mode 100644 index 00000000..ffea6c5b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-10_굴착기_우드그랩_산지집재-병해충방제.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-10. 굴착기 우드그랩 산지집재-병해충방제 +PDF쪽: 106 +인쇄쪽: 94 +시작표지: 7-10. 굴착기 우드그랩 산지집재-병해충방제 +끝표지: 7-11. 동력상하차기(우드그래플) 집재-수확 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-11_동력상하차기(우드그래플)_집재-수확.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-11_동력상하차기(우드그래플)_집재-수확.md new file mode 100644 index 00000000..02ef2bc9 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-11_동력상하차기(우드그래플)_집재-수확.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-11. 동력상하차기(우드그래플) 집재-수확 +PDF쪽: 106~107 +인쇄쪽: 94~95 +시작표지: 7-11. 동력상하차기(우드그래플) 집재-수확 +끝표지: 7-12. 동력상하차기(우드그래플) 집적 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-12_동력상하차기(우드그래플)_집적.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-12_동력상하차기(우드그래플)_집적.md new file mode 100644 index 00000000..7dab46b8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-12_동력상하차기(우드그래플)_집적.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-12. 동력상하차기(우드그래플) 집적 +PDF쪽: 107~108 +인쇄쪽: 95~96 +시작표지: 7-12. 동력상하차기(우드그래플) 집적 +끝표지: 7-13. 검척 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-13_검척.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-13_검척.md new file mode 100644 index 00000000..3118e932 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-13_검척.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-13. 검척 +PDF쪽: 108 +인쇄쪽: 96 +시작표지: 7-13. 검척 +끝표지: 7-14. 원목 운반-수확 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-14_원목_운반-수확.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-14_원목_운반-수확.md new file mode 100644 index 00000000..2dd6735e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-14_원목_운반-수확.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-14. 원목 운반-수확 +PDF쪽: 109 +인쇄쪽: 97 +시작표지: 7-14. 원목 운반-수확 +끝표지: 7-15. 초소형 포워더 운재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-15_초소형_포워더_운재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-15_초소형_포워더_운재.md new file mode 100644 index 00000000..28100d1d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-15_초소형_포워더_운재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-15. 초소형 포워더 운재 +PDF쪽: 109~110 +인쇄쪽: 97~98 +시작표지: 7-15. 초소형 포워더 운재 +끝표지: 7-16. 소형 포워더 운재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-16_소형_포워더_운재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-16_소형_포워더_운재.md new file mode 100644 index 00000000..19e5b0d5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-16_소형_포워더_운재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-16. 소형 포워더 운재 +PDF쪽: 110 +인쇄쪽: 98 +시작표지: 7-16. 소형 포워더 운재 +끝표지: 7-17. 소형트럭 운재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-17_소형트럭_운재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-17_소형트럭_운재.md new file mode 100644 index 00000000..ac8191d3 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-17_소형트럭_운재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-17. 소형트럭 운재 +PDF쪽: 110 +인쇄쪽: 98 +시작표지: 7-17. 소형트럭 운재 +끝표지: 7-18. 지조운반(포워더 활용) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-18_지조운반(포워더_활용).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-18_지조운반(포워더_활용).md new file mode 100644 index 00000000..c8d0bac1 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-18_지조운반(포워더_활용).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 7-18. 지조운반(포워더 활용) +PDF쪽: 111 +인쇄쪽: 99 +시작표지: 7-18. 지조운반(포워더 활용) +끝표지: 제8장 방제 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/0-00_장머리.md new file mode 100644 index 00000000..a1b5005d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제8장 방제 +PDF쪽: 112 +인쇄쪽: 100 +시작표지: 제8장 방제 +끝표지: 8-1. 나무주사 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-01_나무주사.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-01_나무주사.md new file mode 100644 index 00000000..1f33b817 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-01_나무주사.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-1. 나무주사 +PDF쪽: 112 +인쇄쪽: 100 +시작표지: 8-1. 나무주사 +끝표지: 8-2. 약제주입 기준 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-02_약제주입_기준.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-02_약제주입_기준.md new file mode 100644 index 00000000..04331d2f --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-02_약제주입_기준.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-2. 약제주입 기준 +PDF쪽: 113~123 +인쇄쪽: 101~111 +시작표지: 8-2. 약제주입 기준 +끝표지: 8-3. 소각, 매몰, 훈증, 박피 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-03_소각,_매몰,_훈증,_박피.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-03_소각,_매몰,_훈증,_박피.md new file mode 100644 index 00000000..e09d82fc --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-03_소각,_매몰,_훈증,_박피.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-3. 소각, 매몰, 훈증, 박피 +PDF쪽: 124~125 +인쇄쪽: 112~113 +시작표지: 8-3. 소각, 매몰, 훈증, 박피 +끝표지: 8-4. 끈끈이 롤 트랩 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-04_끈끈이_롤_트랩.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-04_끈끈이_롤_트랩.md new file mode 100644 index 00000000..14851856 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-04_끈끈이_롤_트랩.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-4. 끈끈이 롤 트랩 +PDF쪽: 126 +인쇄쪽: 114 +시작표지: 8-4. 끈끈이 롤 트랩 +끝표지: 8-5. 페르몬 유인트랩 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-05_페르몬_유인트랩.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-05_페르몬_유인트랩.md new file mode 100644 index 00000000..4bda7b3a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-05_페르몬_유인트랩.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-5. 페르몬 유인트랩 +PDF쪽: 126 +인쇄쪽: 114 +시작표지: 8-5. 페르몬 유인트랩 +끝표지: 8-6. 약제살포 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-06_약제살포.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-06_약제살포.md new file mode 100644 index 00000000..f0b0f132 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-06_약제살포.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-6. 약제살포 +PDF쪽: 127~130 +인쇄쪽: 115~118 +시작표지: 8-6. 약제살포 +끝표지: 8-7. 방제 실행 등록 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-07_방제_실행_등록.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-07_방제_실행_등록.md new file mode 100644 index 00000000..906aebf2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-07_방제_실행_등록.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-7. 방제 실행 등록 +PDF쪽: 131 +인쇄쪽: 119 +시작표지: 8-7. 방제 실행 등록 +끝표지: 8-8. 훈증더미 제거 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-08_훈증더미_제거.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-08_훈증더미_제거.md new file mode 100644 index 00000000..53dbe9da --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-08_훈증더미_제거.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-8. 훈증더미 제거 +PDF쪽: 131 +인쇄쪽: 119 +시작표지: 8-8. 훈증더미 제거 +끝표지: 8-9. 잔가지줍기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-09_잔가지줍기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-09_잔가지줍기.md new file mode 100644 index 00000000..e8fc43d1 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-09_잔가지줍기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-9. 잔가지줍기 +PDF쪽: 132 +인쇄쪽: 120 +시작표지: 8-9. 잔가지줍기 +끝표지: 8-10. 그물망 피복 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-10_그물망_피복.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-10_그물망_피복.md new file mode 100644 index 00000000..bcd7a265 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-10_그물망_피복.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-10. 그물망 피복 +PDF쪽: 132 +인쇄쪽: 120 +시작표지: 8-10. 그물망 피복 +끝표지: 8-11. 이동식 임목 파쇄 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-11_이동식_임목_파쇄.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-11_이동식_임목_파쇄.md new file mode 100644 index 00000000..dcf1a8a4 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제08장_방제/8-11_이동식_임목_파쇄.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 8-11. 이동식 임목 파쇄 +PDF쪽: 132 +인쇄쪽: 120 +시작표지: 8-11. 이동식 임목 파쇄 +끝표지: 제9장 토공 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/0-00_장머리.md new file mode 100644 index 00000000..5b419afc --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제9장 토공 +PDF쪽: 133 +인쇄쪽: 121 +시작표지: 제9장 토공 +끝표지: 9-1. 굴착 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-01_굴착.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-01_굴착.md new file mode 100644 index 00000000..a7b3a544 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-01_굴착.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-1. 굴착 +PDF쪽: 133 +인쇄쪽: 121 +시작표지: 9-1. 굴착 +끝표지: 9-2. 노선 굴진 보조원 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-02_노선_굴진_보조원.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-02_노선_굴진_보조원.md new file mode 100644 index 00000000..7b0edbce --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-02_노선_굴진_보조원.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-2. 노선 굴진 보조원 +PDF쪽: 133 +인쇄쪽: 121 +시작표지: 9-2. 노선 굴진 보조원 +끝표지: 9-3. 토사깍기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-03_토사깍기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-03_토사깍기.md new file mode 100644 index 00000000..73405c46 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-03_토사깍기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-3. 토사깍기 +PDF쪽: 133~134 +인쇄쪽: 121~122 +시작표지: 9-3. 토사깍기 +끝표지: 9-4. 암절취 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-04_암절취.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-04_암절취.md new file mode 100644 index 00000000..d83330d9 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-04_암절취.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-4. 암절취 +PDF쪽: 134 +인쇄쪽: 122 +시작표지: 9-4. 암절취 +끝표지: 9-5. 발파암 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-05_발파암.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-05_발파암.md new file mode 100644 index 00000000..df61ae08 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-05_발파암.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-5. 발파암 +PDF쪽: 135 +인쇄쪽: 123 +시작표지: 9-5. 발파암 +끝표지: 9-6. 발파암 소할 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-06_발파암_소할.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-06_발파암_소할.md new file mode 100644 index 00000000..02c61221 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-06_발파암_소할.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-6. 발파암 소할 +PDF쪽: 136 +인쇄쪽: 124 +시작표지: 9-6. 발파암 소할 +끝표지: 9-7. 무근콘크리트 깨기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-07_무근콘크리트_깨기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-07_무근콘크리트_깨기.md new file mode 100644 index 00000000..2a214d3a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-07_무근콘크리트_깨기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-7. 무근콘크리트 깨기 +PDF쪽: 136~137 +인쇄쪽: 124~125 +시작표지: 9-7. 무근콘크리트 깨기 +끝표지: 9-8. 철근콘크리트 깨기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-08_철근콘크리트_깨기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-08_철근콘크리트_깨기.md new file mode 100644 index 00000000..aa668994 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-08_철근콘크리트_깨기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-8. 철근콘크리트 깨기 +PDF쪽: 137~138 +인쇄쪽: 125~126 +시작표지: 9-8. 철근콘크리트 깨기 +끝표지: 9-9. 석축헐기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-09_석축헐기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-09_석축헐기.md new file mode 100644 index 00000000..a56064bc --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-09_석축헐기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-9. 석축헐기 +PDF쪽: 139 +인쇄쪽: 127 +시작표지: 9-9. 석축헐기 +끝표지: 9-10. 기존포장 깨기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-10_기존포장_깨기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-10_기존포장_깨기.md new file mode 100644 index 00000000..9e141082 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-10_기존포장_깨기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-10. 기존포장 깨기 +PDF쪽: 139~140 +인쇄쪽: 127~128 +시작표지: 9-10. 기존포장 깨기 +끝표지: 9-11. 포장절단 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-11_포장절단.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-11_포장절단.md new file mode 100644 index 00000000..0a08d3c5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-11_포장절단.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-11. 포장절단 +PDF쪽: 140~141 +인쇄쪽: 128~129 +시작표지: 9-11. 포장절단 +끝표지: 9-12. 측구터파기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-12_측구터파기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-12_측구터파기.md new file mode 100644 index 00000000..5bea19bd --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-12_측구터파기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-12. 측구터파기 +PDF쪽: 141~142 +인쇄쪽: 129~130 +시작표지: 9-12. 측구터파기 +끝표지: 9-13. 구조물터파기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-13_구조물터파기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-13_구조물터파기.md new file mode 100644 index 00000000..a56b7427 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-13_구조물터파기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-13. 구조물터파기 +PDF쪽: 142~147 +인쇄쪽: 130~135 +시작표지: 9-13. 구조물터파기 +끝표지: 9-14. 되메우기 및 다짐 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-14_되메우기_및_다짐.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-14_되메우기_및_다짐.md new file mode 100644 index 00000000..ff78828b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-14_되메우기_및_다짐.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-14. 되메우기 및 다짐 +PDF쪽: 147~148 +인쇄쪽: 135~136 +시작표지: 9-14. 되메우기 및 다짐 +끝표지: 9-15. 표토제거 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-15_표토제거.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-15_표토제거.md new file mode 100644 index 00000000..53ff54cb --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-15_표토제거.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-15. 표토제거 +PDF쪽: 148~149 +인쇄쪽: 136~137 +시작표지: 9-15. 표토제거 +끝표지: 9-16. 노체 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-16_노체.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-16_노체.md new file mode 100644 index 00000000..01dd7ac8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-16_노체.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-16. 노체 +PDF쪽: 149~150 +인쇄쪽: 137~138 +시작표지: 9-16. 노체 +끝표지: 9-17. 다짐 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-17_다짐.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-17_다짐.md new file mode 100644 index 00000000..5a16ae42 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-17_다짐.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-17. 다짐 +PDF쪽: 150~151 +인쇄쪽: 138~139 +시작표지: 9-17. 다짐 +끝표지: 9-18. 층따기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-18_층따기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-18_층따기.md new file mode 100644 index 00000000..68cae3af --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-18_층따기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-18. 층따기 +PDF쪽: 151 +인쇄쪽: 139 +시작표지: 9-18. 층따기 +끝표지: 9-19. 면고르기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-19_면고르기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-19_면고르기.md new file mode 100644 index 00000000..000c0018 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-19_면고르기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-19. 면고르기 +PDF쪽: 152~153 +인쇄쪽: 140~141 +시작표지: 9-19. 면고르기 +끝표지: 9-20. 뿌리다듬기 및 적재 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-20_뿌리다듬기_및_적재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-20_뿌리다듬기_및_적재.md new file mode 100644 index 00000000..f1e45a7c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-20_뿌리다듬기_및_적재.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-20. 뿌리다듬기 및 적재 +PDF쪽: 153~154 +인쇄쪽: 141~142 +시작표지: 9-20. 뿌리다듬기 및 적재 +끝표지: 9-21. 제근 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-21_제근.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-21_제근.md new file mode 100644 index 00000000..279a48d0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-21_제근.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-21. 제근 +PDF쪽: 154 +인쇄쪽: 142 +시작표지: 9-21. 제근 +끝표지: 9-22. 섞기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-22_섞기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-22_섞기.md new file mode 100644 index 00000000..9036803a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제09장_토공/9-22_섞기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 9-22. 섞기 +PDF쪽: 154 +인쇄쪽: 142 +시작표지: 9-22. 섞기 +끝표지: 제10장 자재․장비 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/0-00_장머리.md new file mode 100644 index 00000000..09ff762b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제10장 자재․장비 운반 +PDF쪽: 155 +인쇄쪽: 143 +시작표지: 제10장 자재․장비 운반 +끝표지: 10-1. 시멘트운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-01_시멘트운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-01_시멘트운반.md new file mode 100644 index 00000000..91d8fa48 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-01_시멘트운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-1. 시멘트운반 +PDF쪽: 155 +인쇄쪽: 143 +시작표지: 10-1. 시멘트운반 +끝표지: 10-2. 철근운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-02_철근운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-02_철근운반.md new file mode 100644 index 00000000..31c7dfc9 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-02_철근운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-2. 철근운반 +PDF쪽: 155 +인쇄쪽: 143 +시작표지: 10-2. 철근운반 +끝표지: 10-3. 골재운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-03_골재운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-03_골재운반.md new file mode 100644 index 00000000..eef06e4d --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-03_골재운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-3. 골재운반 +PDF쪽: 155~156 +인쇄쪽: 143~144 +시작표지: 10-3. 골재운반 +끝표지: 10-4. 중기운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-04_중기운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-04_중기운반.md new file mode 100644 index 00000000..c076dfe6 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-04_중기운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-4. 중기운반 +PDF쪽: 156 +인쇄쪽: 144 +시작표지: 10-4. 중기운반 +끝표지: 10-5. 레미콘 운반비 산정 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-05_레미콘_운반비_산정.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-05_레미콘_운반비_산정.md new file mode 100644 index 00000000..6f1e167a --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-05_레미콘_운반비_산정.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-5. 레미콘 운반비 산정 +PDF쪽: 156 +인쇄쪽: 144 +시작표지: 10-5. 레미콘 운반비 산정 +끝표지: 10-6. 인력운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-06_인력운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-06_인력운반.md new file mode 100644 index 00000000..554a3c9b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-06_인력운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-6. 인력운반 +PDF쪽: 157 +인쇄쪽: 145 +시작표지: 10-6. 인력운반 +끝표지: 10-7. 모노레일 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-07_모노레일_운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-07_모노레일_운반.md new file mode 100644 index 00000000..552e1366 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-07_모노레일_운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-7. 모노레일 운반 +PDF쪽: 158~160 +인쇄쪽: 146~148 +시작표지: 10-7. 모노레일 운반 +끝표지: 10-8. 케이블 크레인 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-08_케이블_크레인_운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-08_케이블_크레인_운반.md new file mode 100644 index 00000000..19f6ec5c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-08_케이블_크레인_운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-8. 케이블 크레인 운반 +PDF쪽: 161~162 +인쇄쪽: 149~150 +시작표지: 10-8. 케이블 크레인 운반 +끝표지: 10-9. 덤프트럭 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-09_덤프트럭_운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-09_덤프트럭_운반.md new file mode 100644 index 00000000..2bd857f8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-09_덤프트럭_운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-9. 덤프트럭 운반 +PDF쪽: 162 +인쇄쪽: 150 +시작표지: 10-9. 덤프트럭 운반 +끝표지: 10-10. 헬리콥터 자재 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-10_헬리콥터_자재_운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-10_헬리콥터_자재_운반.md new file mode 100644 index 00000000..9d3fa632 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-10_헬리콥터_자재_운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-10. 헬리콥터 자재 운반 +PDF쪽: 163 +인쇄쪽: 151 +시작표지: 10-10. 헬리콥터 자재 운반 +끝표지: 10-11. 불도저 운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-11_불도저_운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-11_불도저_운반.md new file mode 100644 index 00000000..55d25a7b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-11_불도저_운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-11. 불도저 운반 +PDF쪽: 164 +인쇄쪽: 152 +시작표지: 10-11. 불도저 운반 +끝표지: 10-12. 덤프운반 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-12_덤프운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-12_덤프운반.md new file mode 100644 index 00000000..3f658c60 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-12_덤프운반.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-12. 덤프운반 +PDF쪽: 164~166 +인쇄쪽: 152~154 +시작표지: 10-12. 덤프운반 +끝표지: 10-13. 드론운반공 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-13_드론운반공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-13_드론운반공.md new file mode 100644 index 00000000..22af0801 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제10장_자재․장비_운반/10-13_드론운반공.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 10-13. 드론운반공 +PDF쪽: 166~167 +인쇄쪽: 154~155 +시작표지: 10-13. 드론운반공 +끝표지: 제11장 가설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/0-00_장머리.md new file mode 100644 index 00000000..af585c9e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제11장 가설 +PDF쪽: 168 +인쇄쪽: 156 +시작표지: 제11장 가설 +끝표지: 11-1. 콘테이너형 가설건축물 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-01_콘테이너형_가설건축물.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-01_콘테이너형_가설건축물.md new file mode 100644 index 00000000..3cb156d8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-01_콘테이너형_가설건축물.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 11-1. 콘테이너형 가설건축물 +PDF쪽: 168 +인쇄쪽: 156 +시작표지: 11-1. 콘테이너형 가설건축물 +끝표지: 11-2. 토공의 비탈 규준틀 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-02_토공의_비탈_규준틀.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-02_토공의_비탈_규준틀.md new file mode 100644 index 00000000..12d8aa82 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-02_토공의_비탈_규준틀.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 11-2. 토공의 비탈 규준틀 +PDF쪽: 168 +인쇄쪽: 156 +시작표지: 11-2. 토공의 비탈 규준틀 +끝표지: 11-3. 수평규준틀 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-03_수평규준틀.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-03_수평규준틀.md new file mode 100644 index 00000000..731bd6b1 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-03_수평규준틀.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 11-3. 수평규준틀 +PDF쪽: 169 +인쇄쪽: 157 +시작표지: 11-3. 수평규준틀 +끝표지: 11-4. 쇄석·혼합석 부설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-04_쇄석·혼합석_부설.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-04_쇄석·혼합석_부설.md new file mode 100644 index 00000000..3a743214 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제11장_가설/11-04_쇄석·혼합석_부설.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 11-4. 쇄석·혼합석 부설 +PDF쪽: 169 +인쇄쪽: 157 +시작표지: 11-4. 쇄석·혼합석 부설 +끝표지: 제12장 철근콘크리트 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/0-00_장머리.md new file mode 100644 index 00000000..27173603 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제12장 철근콘크리트 +PDF쪽: 170 +인쇄쪽: 158 +시작표지: 제12장 철근콘크리트 +끝표지: 12-1. 콘크리트타설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-01_콘크리트타설.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-01_콘크리트타설.md new file mode 100644 index 00000000..f070a1ff --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-01_콘크리트타설.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-1. 콘크리트타설 +PDF쪽: 170~171 +인쇄쪽: 158~159 +시작표지: 12-1. 콘크리트타설 +끝표지: 12-2. 표면 마무리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-02_표면_마무리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-02_표면_마무리.md new file mode 100644 index 00000000..4e437d62 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-02_표면_마무리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-2. 표면 마무리 +PDF쪽: 171 +인쇄쪽: 159 +시작표지: 12-2. 표면 마무리 +끝표지: 12-3. 철근 현장가공 및 조립 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-03_철근_현장가공_및_조립.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-03_철근_현장가공_및_조립.md new file mode 100644 index 00000000..dd1c3f4c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-03_철근_현장가공_및_조립.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-3. 철근 현장가공 및 조립 +PDF쪽: 172 +인쇄쪽: 160 +시작표지: 12-3. 철근 현장가공 및 조립 +끝표지: 12-4. 합판거푸집 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-04_합판거푸집.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-04_합판거푸집.md new file mode 100644 index 00000000..faa92a10 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-04_합판거푸집.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-4. 합판거푸집 +PDF쪽: 172~173 +인쇄쪽: 160~161 +시작표지: 12-4. 합판거푸집 +끝표지: 12-5. 문양거푸집(0~7m) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-05_문양거푸집(0~7m).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-05_문양거푸집(0~7m).md new file mode 100644 index 00000000..397fa3a0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-05_문양거푸집(0~7m).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-5. 문양거푸집(0~7m) +PDF쪽: 173 +인쇄쪽: 161 +시작표지: 12-5. 문양거푸집(0~7m) +끝표지: 12-6. 콘크리트 포장(인력시공) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-06_콘크리트_포장(인력시공).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-06_콘크리트_포장(인력시공).md new file mode 100644 index 00000000..8f274c91 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-06_콘크리트_포장(인력시공).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-6. 콘크리트 포장(인력시공) +PDF쪽: 173~174 +인쇄쪽: 161~162 +시작표지: 12-6. 콘크리트 포장(인력시공) +끝표지: 12-7. 포장절단 및 줄눈설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-07_포장절단_및_줄눈설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-07_포장절단_및_줄눈설치.md new file mode 100644 index 00000000..6eb3c1bf --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-07_포장절단_및_줄눈설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-7. 포장절단 및 줄눈설치 +PDF쪽: 174 +인쇄쪽: 162 +시작표지: 12-7. 포장절단 및 줄눈설치 +끝표지: 12-8. 콘크리트 포장 거푸집 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-08_콘크리트_포장_거푸집.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-08_콘크리트_포장_거푸집.md new file mode 100644 index 00000000..aa9634e7 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-08_콘크리트_포장_거푸집.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-8. 콘크리트 포장 거푸집 +PDF쪽: 174 +인쇄쪽: 162 +시작표지: 12-8. 콘크리트 포장 거푸집 +끝표지: 12-9. 측구 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-09_측구.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-09_측구.md new file mode 100644 index 00000000..9385a180 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-09_측구.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-9. 측구 +PDF쪽: 175 +인쇄쪽: 163 +시작표지: 12-9. 측구 +끝표지: 12-10. 맹암거 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-10_맹암거.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-10_맹암거.md new file mode 100644 index 00000000..ae1c77db --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-10_맹암거.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-10. 맹암거 +PDF쪽: 176 +인쇄쪽: 164 +시작표지: 12-10. 맹암거 +끝표지: 12-11. 관부설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-11_관부설.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-11_관부설.md new file mode 100644 index 00000000..716fe561 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-11_관부설.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-11. 관부설 +PDF쪽: 176~177 +인쇄쪽: 164~165 +시작표지: 12-11. 관부설 +끝표지: 12-12. 날개벽 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-12_날개벽.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-12_날개벽.md new file mode 100644 index 00000000..e8a64bd5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-12_날개벽.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-12. 날개벽 +PDF쪽: 178 +인쇄쪽: 166 +시작표지: 12-12. 날개벽 +끝표지: 12-13. 면벽 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-13_면벽.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-13_면벽.md new file mode 100644 index 00000000..2b33b43e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-13_면벽.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-13. 면벽 +PDF쪽: 178 +인쇄쪽: 166 +시작표지: 12-13. 면벽 +끝표지: 12-14. 가배수관 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-14_가배수관.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-14_가배수관.md new file mode 100644 index 00000000..72fce70c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-14_가배수관.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-14. 가배수관 +PDF쪽: 179 +인쇄쪽: 167 +시작표지: 12-14. 가배수관 +끝표지: 12-15. 집수정 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-15_집수정.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-15_집수정.md new file mode 100644 index 00000000..68a15492 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-15_집수정.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-15. 집수정 +PDF쪽: 179 +인쇄쪽: 167 +시작표지: 12-15. 집수정 +끝표지: 12-16. 맨홀 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-16_맨홀.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-16_맨홀.md new file mode 100644 index 00000000..3222c918 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-16_맨홀.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-16. 맨홀 +PDF쪽: 180 +인쇄쪽: 168 +시작표지: 12-16. 맨홀 +끝표지: 12-17. 펌프카 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-17_펌프카.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-17_펌프카.md new file mode 100644 index 00000000..25f7a915 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-17_펌프카.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-17. 펌프카 +PDF쪽: 181~183 +인쇄쪽: 169~171 +시작표지: 12-17. 펌프카 +끝표지: 12-18. 철근가공조립(복잡) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-18_철근가공조립(복잡).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-18_철근가공조립(복잡).md new file mode 100644 index 00000000..c837e62b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-18_철근가공조립(복잡).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-18. 철근가공조립(복잡) +PDF쪽: 183 +인쇄쪽: 171 +시작표지: 12-18. 철근가공조립(복잡) +끝표지: 12-19. 강관비계 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-19_강관비계.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-19_강관비계.md new file mode 100644 index 00000000..98a45f92 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-19_강관비계.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-19. 강관비계 +PDF쪽: 183 +인쇄쪽: 171 +시작표지: 12-19. 강관비계 +끝표지: 12-20. 강관동바리 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-20_강관동바리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-20_강관동바리.md new file mode 100644 index 00000000..ccb6876f --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-20_강관동바리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-20. 강관동바리 +PDF쪽: 184 +인쇄쪽: 172 +시작표지: 12-20. 강관동바리 +끝표지: 12-21. 아스팔트코팅(2회) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-21_아스팔트코팅(2회).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-21_아스팔트코팅(2회).md new file mode 100644 index 00000000..6b8788f5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-21_아스팔트코팅(2회).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-21. 아스팔트코팅(2회) +PDF쪽: 184 +인쇄쪽: 172 +시작표지: 12-21. 아스팔트코팅(2회) +끝표지: 12-22. P.V.C 파이프 설치(50mm) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-22_P.V.C_파이프_설치(50mm).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-22_P.V.C_파이프_설치(50mm).md new file mode 100644 index 00000000..d17e1719 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-22_P.V.C_파이프_설치(50mm).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-22. P.V.C 파이프 설치(50mm) +PDF쪽: 184 +인쇄쪽: 172 +시작표지: 12-22. P.V.C 파이프 설치(50mm) +끝표지: 12-23. 부직포설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-23_부직포설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-23_부직포설치.md new file mode 100644 index 00000000..83a96ecf --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-23_부직포설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-23. 부직포설치 +PDF쪽: 184 +인쇄쪽: 172 +시작표지: 12-23. 부직포설치 +끝표지: 12-24. 뒷채움 및 되메우기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-24_뒷채움_및_되메우기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-24_뒷채움_및_되메우기.md new file mode 100644 index 00000000..733819d8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-24_뒷채움_및_되메우기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-24. 뒷채움 및 되메우기 +PDF쪽: 185 +인쇄쪽: 173 +시작표지: 12-24. 뒷채움 및 되메우기 +끝표지: 12-25. 기초잡석 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-25_기초잡석.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-25_기초잡석.md new file mode 100644 index 00000000..c7209bd8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-25_기초잡석.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-25. 기초잡석 +PDF쪽: 185 +인쇄쪽: 173 +시작표지: 12-25. 기초잡석 +끝표지: 12-26. 물푸기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-26_물푸기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-26_물푸기.md new file mode 100644 index 00000000..8d3de896 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-26_물푸기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-26. 물푸기 +PDF쪽: 186 +인쇄쪽: 174 +시작표지: 12-26. 물푸기 +끝표지: 12-27. 지수판 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-27_지수판.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-27_지수판.md new file mode 100644 index 00000000..9e1aa669 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-27_지수판.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-27. 지수판 +PDF쪽: 186 +인쇄쪽: 174 +시작표지: 12-27. 지수판 +끝표지: 12-28. 신구 BOX접합 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-28_신구_BOX접합.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-28_신구_BOX접합.md new file mode 100644 index 00000000..c4947015 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-28_신구_BOX접합.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-28. 신구 BOX접합 +PDF쪽: 187 +인쇄쪽: 175 +시작표지: 12-28. 신구 BOX접합 +끝표지: 12-29. 스페이셔 설치(몰탈 블록) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-29_스페이셔_설치(몰탈_블록).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-29_스페이셔_설치(몰탈_블록).md new file mode 100644 index 00000000..50180b20 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-29_스페이셔_설치(몰탈_블록).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-29. 스페이셔 설치(몰탈 블록) +PDF쪽: 187 +인쇄쪽: 175 +시작표지: 12-29. 스페이셔 설치(몰탈 블록) +끝표지: 12-30. 다웰바 설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-30_다웰바_설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-30_다웰바_설치.md new file mode 100644 index 00000000..b9a4e7a5 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-30_다웰바_설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-30. 다웰바 설치 +PDF쪽: 187 +인쇄쪽: 175 +시작표지: 12-30. 다웰바 설치 +끝표지: 12-31. 물끊기 홈(NOTCH) 설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-31_물끊기_홈(NOTCH)_설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-31_물끊기_홈(NOTCH)_설치.md new file mode 100644 index 00000000..a0f3bbe2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-31_물끊기_홈(NOTCH)_설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-31. 물끊기 홈(NOTCH) 설치 +PDF쪽: 188 +인쇄쪽: 176 +시작표지: 12-31. 물끊기 홈(NOTCH) 설치 +끝표지: 12-32. 전선관 설치(P.V.C ø54m/m) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-32_전선관_설치(P.V.C_ø54mm).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-32_전선관_설치(P.V.C_ø54mm).md new file mode 100644 index 00000000..9e6122c0 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-32_전선관_설치(P.V.C_ø54mm).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-32. 전선관 설치(P.V.C ø54m/m) +PDF쪽: 188 +인쇄쪽: 176 +시작표지: 12-32. 전선관 설치(P.V.C ø54m/m) +끝표지: 12-33. 비닐깔기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-33_비닐깔기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-33_비닐깔기.md new file mode 100644 index 00000000..c239093c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-33_비닐깔기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-33. 비닐깔기 +PDF쪽: 188 +인쇄쪽: 176 +시작표지: 12-33. 비닐깔기 +끝표지: 12-34. 암거이음받침 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-34_암거이음받침.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-34_암거이음받침.md new file mode 100644 index 00000000..a5c4c2cf --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-34_암거이음받침.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-34. 암거이음받침 +PDF쪽: 188~189 +인쇄쪽: 176~177 +시작표지: 12-34. 암거이음받침 +끝표지: 12-35. 콘크리트 표면 강화재(하드너) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-35_콘크리트_표면_강화재(하드너).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-35_콘크리트_표면_강화재(하드너).md new file mode 100644 index 00000000..96354e12 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-35_콘크리트_표면_강화재(하드너).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-35. 콘크리트 표면 강화재(하드너) +PDF쪽: 189 +인쇄쪽: 177 +시작표지: 12-35. 콘크리트 표면 강화재(하드너) +끝표지: 12-36. P.C BOX 설치 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-36_P.C_BOX_설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-36_P.C_BOX_설치.md new file mode 100644 index 00000000..f04a4f75 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-36_P.C_BOX_설치.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-36. P.C BOX 설치 +PDF쪽: 189 +인쇄쪽: 177 +시작표지: 12-36. P.C BOX 설치 +끝표지: 12-37. 콘크리트 타설(무근진동기 제외) +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-37_콘크리트_타설(무근진동기_제외).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-37_콘크리트_타설(무근진동기_제외).md new file mode 100644 index 00000000..b1e66592 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-37_콘크리트_타설(무근진동기_제외).md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-37. 콘크리트 타설(무근진동기 제외) +PDF쪽: 190 +인쇄쪽: 178 +시작표지: 12-37. 콘크리트 타설(무근진동기 제외) +끝표지: 12-38. 유로폼 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-38_유로폼.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-38_유로폼.md new file mode 100644 index 00000000..2ba711ff --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-38_유로폼.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 12-38. 유로폼 +PDF쪽: 190~191 +인쇄쪽: 178~179 +시작표지: 12-38. 유로폼 +끝표지: 제13장 구조물 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/0-00_장머리.md new file mode 100644 index 00000000..c16f5cc7 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제13장 구조물 +PDF쪽: 192 +인쇄쪽: 180 +시작표지: 제13장 구조물 +끝표지: 13-1. 석재 및 골재의 분류 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-01_석재_및_골재의_분류.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-01_석재_및_골재의_분류.md new file mode 100644 index 00000000..2555ffa2 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-01_석재_및_골재의_분류.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-1. 석재 및 골재의 분류 +PDF쪽: 192 +인쇄쪽: 180 +시작표지: 13-1. 석재 및 골재의 분류 +끝표지: 13-2. 채집 및 세척 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-02_채집_및_세척.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-02_채집_및_세척.md new file mode 100644 index 00000000..c127b3c8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-02_채집_및_세척.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-2. 채집 및 세척 +PDF쪽: 192~193 +인쇄쪽: 180~181 +시작표지: 13-2. 채집 및 세척 +끝표지: 13-3. 기초다짐 및 뒷채움 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-03_기초다짐_및_뒷채움.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-03_기초다짐_및_뒷채움.md new file mode 100644 index 00000000..1aa234b4 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-03_기초다짐_및_뒷채움.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-3. 기초다짐 및 뒷채움 +PDF쪽: 194 +인쇄쪽: 182 +시작표지: 13-3. 기초다짐 및 뒷채움 +끝표지: 13-4. 돌쌓기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-04_돌쌓기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-04_돌쌓기.md new file mode 100644 index 00000000..95527a59 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-04_돌쌓기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-4. 돌쌓기 +PDF쪽: 195~199 +인쇄쪽: 183~187 +시작표지: 13-4. 돌쌓기 +끝표지: 13-5. 돌붙임 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-05_돌붙임.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-05_돌붙임.md new file mode 100644 index 00000000..21f92670 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-05_돌붙임.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-5. 돌붙임 +PDF쪽: 199~200 +인쇄쪽: 187~188 +시작표지: 13-5. 돌붙임 +끝표지: 13-6. 큰돌쌓기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-06_큰돌쌓기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-06_큰돌쌓기.md new file mode 100644 index 00000000..a7fd9f66 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-06_큰돌쌓기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-6. 큰돌쌓기 +PDF쪽: 201~202 +인쇄쪽: 189~190 +시작표지: 13-6. 큰돌쌓기 +끝표지: 13-7. 큰돌붙이기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-07_큰돌붙이기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-07_큰돌붙이기.md new file mode 100644 index 00000000..15147c5e --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-07_큰돌붙이기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-7. 큰돌붙이기 +PDF쪽: 203~204 +인쇄쪽: 191~192 +시작표지: 13-7. 큰돌붙이기 +끝표지: 13-8. 막돌쌓기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-08_막돌쌓기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-08_막돌쌓기.md new file mode 100644 index 00000000..f5918d54 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-08_막돌쌓기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-8. 막돌쌓기 +PDF쪽: 204 +인쇄쪽: 192 +시작표지: 13-8. 막돌쌓기 +끝표지: 13-9. 서식지 조성 돌쌓기 및 놓기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-09_서식지_조성_돌쌓기_및_놓기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-09_서식지_조성_돌쌓기_및_놓기.md new file mode 100644 index 00000000..e0ff189b --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-09_서식지_조성_돌쌓기_및_놓기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-9. 서식지 조성 돌쌓기 및 놓기 +PDF쪽: 204 +인쇄쪽: 192 +시작표지: 13-9. 서식지 조성 돌쌓기 및 놓기 +끝표지: 13-10. 말뚝박기공 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-10_말뚝박기공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-10_말뚝박기공.md new file mode 100644 index 00000000..c0cbe632 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-10_말뚝박기공.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-10. 말뚝박기공 +PDF쪽: 205~207 +인쇄쪽: 193~195 +시작표지: 13-10. 말뚝박기공 +끝표지: 13-11. 돌망태 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-11_돌망태.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-11_돌망태.md new file mode 100644 index 00000000..276d6a2c --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-11_돌망태.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-11. 돌망태 +PDF쪽: 208~210 +인쇄쪽: 196~198 +시작표지: 13-11. 돌망태 +끝표지: 13-12. 비탈다듬기 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-12_비탈다듬기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-12_비탈다듬기.md new file mode 100644 index 00000000..f2fdd6f8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-12_비탈다듬기.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-12. 비탈다듬기 +PDF쪽: 210~211 +인쇄쪽: 198~199 +시작표지: 13-12. 비탈다듬기 +끝표지: 13-13. 흙막이 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-13_흙막이.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-13_흙막이.md new file mode 100644 index 00000000..9591c608 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-13_흙막이.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-13. 흙막이 +PDF쪽: 211~212 +인쇄쪽: 199~200 +시작표지: 13-13. 흙막이 +끝표지: 13-14. 식생토낭 및 포트 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-14_식생토낭_및_포트.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-14_식생토낭_및_포트.md new file mode 100644 index 00000000..b3430e00 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-14_식생토낭_및_포트.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-14. 식생토낭 및 포트 +PDF쪽: 212~213 +인쇄쪽: 200~201 +시작표지: 13-14. 식생토낭 및 포트 +끝표지: 13-15. 목재공 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-15_목재공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-15_목재공.md new file mode 100644 index 00000000..ba9e7dd8 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-15_목재공.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-15. 목재공 +PDF쪽: 213~214 +인쇄쪽: 201~202 +시작표지: 13-15. 목재공 +끝표지: 13-16. 매트부설 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-16_매트부설.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-16_매트부설.md new file mode 100644 index 00000000..92b0c896 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제13장_구조물/13-16_매트부설.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 13-16. 매트부설 +PDF쪽: 214 +인쇄쪽: 202 +시작표지: 13-16. 매트부설 +끝표지: 제14장 부대공 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/0-00_장머리.md new file mode 100644 index 00000000..fcd20402 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/0-00_장머리.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 제14장 부대공 +PDF쪽: 215 +인쇄쪽: 203 +시작표지: 제14장 부대공 +끝표지: 14-1. 입목뿌리 밑막이 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-01_입목뿌리_밑막이.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-01_입목뿌리_밑막이.md new file mode 100644 index 00000000..5803d688 --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-01_입목뿌리_밑막이.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 14-1. 입목뿌리 밑막이 +PDF쪽: 215 +인쇄쪽: 203 +시작표지: 14-1. 입목뿌리 밑막이 +끝표지: 14-2. 근주이식 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-02_근주이식.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-02_근주이식.md new file mode 100644 index 00000000..c4db30ef --- /dev/null +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제14장_부대공/14-02_근주이식.md @@ -0,0 +1,14 @@ +--- +문서: 산림사업 표준품셈(산림청고시 제2025-82호) +절: 14-2. 근주이식 +PDF쪽: 215 +인쇄쪽: 203 +시작표지: 14-2. 근주이식 +끝표지: [부록 1]할인․할증 적용 공종표 +상태: 대기 +작성: +검증: +원천: 글자층 +기계검사: +확정지문: +--- diff --git a/resources/tester/helper_z01_fake_repo.py b/resources/tester/helper_z01_fake_repo.py new file mode 100644 index 00000000..f20f5ddd --- /dev/null +++ b/resources/tester/helper_z01_fake_repo.py @@ -0,0 +1,92 @@ +"""Z01 기초단가 저장소 가짜 — DB 없이 API 시험(PLAN 1-2 · 2026-09-18). + +`install(monkeypatch)` 가 `Z01_MasterData_Repository_BasePrices` 의 함수 다섯을 바꿔 끼움. +주입 = 파일 원본 줄(`base_rows`) 그대로 · ⚠ '@' 칸(`@axis`·`@source`)은 **빼고** 담음 — +주입기가 안쪽 칸을 빼도 서버가 서는지 함께 잼. 판번호 규칙(다르면 409 · 쓰면 +1)은 진짜와 같게. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices +from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo +from Z01_MasterData import Z01_MasterData_WorkItems as work_items + + +class FakeRepo: + def __init__(self) -> None: + self.tables: dict[str, list[dict[str, Any]]] = {} + self.revisions: dict[str, int] = {} + self.saved_by: list[Any] = [] + + def table(self, kind: str) -> list[dict[str, Any]]: + if kind not in self.tables: + self.tables[kind] = [] + for row in base_prices.base_rows(kind): + data = {k: v for k, v in row.items() if not k.startswith("@")} + self.tables[kind].append( + {"row_key": row["@id"], "data": data, "seed": dict(data), "deleted": False} + ) + return self.tables[kind] + + def by_key(self, kind: str) -> dict[str, dict[str, Any]]: + return {r["row_key"]: r for r in self.table(kind)} + + async def load(self, kind: str) -> list[dict[str, Any]]: + return [ + {"row_key": r["row_key"], "data": dict(r["data"]), "seed": r["seed"]} + for r in self.table(kind) + if not r["deleted"] + ] + + async def revision(self, kind: str) -> int: + return self.revisions.get(kind, 0) + + async def counts(self) -> dict[str, int]: + return { + kind: sum(not r["deleted"] for r in self.table(kind)) + for kind in base_prices.KINDS + if kind not in work_items.KINDS + } + + async def save(self, kind, base_revision, changed, added, deleted, by) -> int: + current = self.revisions.get(kind, 0) + if current != base_revision: + raise repo.RevisionConflict(current) + rows = self.by_key(kind) + for key, data in changed.items(): + if not rows[key]["deleted"]: + rows[key]["data"] = dict(data) + for key, data in added.items(): + self.table(kind).append( + {"row_key": key, "data": dict(data), "seed": None, "deleted": False} + ) + for key in deleted: + rows[key]["deleted"] = True + self.revisions[kind] = current + 1 + self.saved_by.append(by) + return current + 1 + + async def reset(self, kind, row_keys, by) -> int: + rows = self.by_key(kind) + missing = sorted(set(row_keys) - set(rows)) + if missing: + raise repo.UnknownRows(missing) + for key in row_keys: + row = rows[key] + if row["seed"] is None: + row["deleted"] = True + else: + row["data"], row["deleted"] = dict(row["seed"]), False + self.revisions[kind] = self.revisions.get(kind, 0) + 1 + return self.revisions[kind] + + +def install(monkeypatch: pytest.MonkeyPatch) -> FakeRepo: + fake = FakeRepo() + for name in ("load", "revision", "counts", "save", "reset"): + monkeypatch.setattr(repo, name, getattr(fake, name)) + return fake diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index 67ec9b59..a67fae7f 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -10,10 +10,12 @@ from __future__ import annotations +import asyncio import json from pathlib import Path import pytest +from helper_z01_fake_repo import install from Z01_MasterData import Z01_MasterData_BasePrices as base_prices @@ -28,11 +30,13 @@ def labels() -> dict: @pytest.fixture(scope="module") def shown() -> dict[str, list[dict]]: - """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다.""" + """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다(DB 는 파일을 주입한 가짜).""" out = {} - for kind in base_prices.KINDS: - columns = base_prices._columns(kind, base_prices.rows(kind)) - out[kind] = [base_prices.column_meta(kind, c) for c in columns] + with pytest.MonkeyPatch.context() as patch: + install(patch) + for kind in base_prices.KINDS: + columns = base_prices._columns(kind, asyncio.run(base_prices.rows(kind))) + out[kind] = [base_prices.column_meta(kind, c) for c in columns] return out @@ -99,8 +103,8 @@ def test_요율_축이_한_칸으로_안_합쳐졌다(shown): def test_요율_줄_열쇠에_축이_살아_있다(): - """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다.""" - rows = base_prices.rows("rate") + """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 주입 열쇠(row_key)가 흔들린다.""" + rows = base_prices.base_rows("rate") multi = [r for r in rows if "/" in str(r["@id"])] assert multi, "축이 든 줄 열쇠가 하나도 없다" assert any("=" in str(r["@id"]) for r in multi) @@ -109,7 +113,7 @@ def test_요율_줄_열쇠에_축이_살아_있다(): def test_기계_시간당_단가는_계산값으로_잠긴다(shown): """밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다).""" - meta = base_prices.table("machine", page=1, size=1) + meta = base_prices.table("machine", base_prices.base_rows("machine"), page=1, size=1) assert set(meta["formula"]) == { "hourly_loss_krw", "hourly_fuel_krw", @@ -155,15 +159,15 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown): def test_줄_열쇠가_서버_축과_같다(labels): - """⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다. + """⭐ 줄을 붙드는 끈 — 파일 원본이 줄마다 `@axis` 로 내는 축이 곧 열쇠(주입 row_key)다. - 이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다. + 이름표가 축을 빠뜨리면 새 판 주입 때 같은 줄을 못 알아본다. """ declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]} bad = [] for kind in base_prices.KINDS: axes = set() - for row in base_prices.rows(kind): + for row in base_prices.base_rows(kind): axes |= set(row.get("@axis") or ()) if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다 continue @@ -174,13 +178,13 @@ def test_줄_열쇠가_서버_축과_같다(labels): def test_구간_이름은_원문_표기_그대로다(): - """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다.""" + """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 주입한 줄을 못 알아본다.""" for kind in ("masonry_slope", "masonry_back_length"): - brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)} + brackets = {str(r.get("height_bracket")) for r in base_prices.base_rows(kind)} assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets)) classes = { str(r.get("class")) - for r in base_prices.rows("masonry_class") + for r in base_prices.base_rows("masonry_class") if r.get("table") == "boulder_diameter" } assert classes == {"40~60", "60~80", "80~100"}, sorted(classes) @@ -198,13 +202,13 @@ def test_축_칸은_못_고친다(): """축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다.""" bad = [] for kind in base_prices.KINDS: - rows = base_prices.rows(kind) + rows = base_prices.base_rows(kind) axes = set() for row in rows: axes |= set(row.get("@axis") or ()) if not axes: continue - meta = base_prices.table(kind, page=1, size=1) + meta = base_prices.table(kind, rows, page=1, size=1) for axis in axes: if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") @@ -228,10 +232,11 @@ def test_기초단가_상자_이름이_이름표에_있다(labels): assert named["pumsem_basis"]["name_ko"] == "품셈 기준" -def test_상자마다_kind_가_있다(): +def test_상자마다_kind_가_있다(monkeypatch): """빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다.""" + install(monkeypatch) counted: dict[str, int] = {} - for entry in base_prices.kinds(): + for entry in asyncio.run(base_prices.kinds()): counted[entry["group"]] = counted.get(entry["group"], 0) + 1 assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다" assert entry["rows"] > 0, entry["kind"] diff --git a/resources/tester/test_pum_md_locked.py b/resources/tester/test_pum_md_locked.py new file mode 100644 index 00000000..e9a07743 --- /dev/null +++ b/resources/tester/test_pum_md_locked.py @@ -0,0 +1,42 @@ +"""품셈 md 잠금 — 상태 `확정` 파일은 확정지문이 맞고 기계검사 ①② 가 0 (규칙 6장). + +`확정` 뒤 파일이 바뀌면 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline")) + +import pum_md_tool as tool # noqa: E402 + +LOCKED = [ + path + for book in tool.BOOKS.values() + for path in sorted(book["out"].rglob("*.md")) + if tool.read_head(path).get("상태") == "확정" +] + + +@pytest.mark.parametrize("path", LOCKED, ids=lambda p: p.name) +def test_locked_file_unchanged_and_clean(path): + head = tool.read_head(path) + assert head["확정지문"], "확정인데 지문이 없음 — check 를 돌려 적을 것" + assert tool.fingerprint(path.read_text(encoding="utf-8")) == head["확정지문"], ( + "확정 뒤 파일이 바뀜 — 상태를 작성중으로 되돌리고 ①②③ 다시" + ) + pytest.importorskip("pymupdf") + _, line = tool.check_file(path) + assert line.startswith("통과"), line + + +def test_heads_are_well_formed(): + """모든 새 md 머리 — 칸이 다 있고 상태가 넷 중 하나.""" + for book in tool.BOOKS.values(): + for path in book["out"].rglob("*.md"): + head = tool.read_head(path) + assert set(tool.HEAD_KEYS) <= set(head), path + assert head["상태"] in tool.STATES, f"{path}: {head['상태']}" diff --git a/resources/tester/test_pum_md_tool.py b/resources/tester/test_pum_md_tool.py new file mode 100644 index 00000000..8832e50f --- /dev/null +++ b/resources/tester/test_pum_md_tool.py @@ -0,0 +1,123 @@ +"""품셈 PDF → 절별 md 도구(`_pipeline/pum_md_tool.py`) — 순수 함수 · 산림 차례 개수. + +규칙: `resources/knowledge/original/원가계산/_품셈md_작성규칙.md`. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline")) + +import pum_md_tool as tool # noqa: E402 + + +def test_parse_toc_strips_leaders_and_reads_appendix(): + lines = ["제1장", "적용기준 ·········", "1", "1-1", "일반사항 ·····", "1", "부록 ······", "204"] + got = tool.parse_toc(lines) + assert [(e.ident, e.name, e.page) for e in got] == [ + ("제1장", "적용기준", 1), + ("1-1", "일반사항", 1), + ("부록", "", 204), + ] + with pytest.raises(ValueError): + tool.parse_toc(["엉뚱한 줄", "1"]) + + +def test_key_ignores_spaces_dot_after_number_and_middle_dots(): + assert tool.key("9-5. 발파암") == tool.key("9-5 발파암") + assert tool.key("[부록1]할인") == tool.key("부록1 할인") + assert tool.key("보수·복구") == tool.key("보수․복구") + + +def test_heading_pattern_by_number_only(): + sec = tool.heading_pattern("7-1") + assert sec.match("7-1. 벌도") + assert not sec.match("7-11. 동력상하차기") # 번호가 앞머리만 같음 + assert not sec.match("7-1-1. 항") + assert tool.heading_pattern("제1장").match("제1장적용기준") + assert not tool.heading_pattern("제1장").match("제10장자재") + assert tool.heading_pattern("부록1").match("[부록1]할인") + assert not tool.heading_pattern("부록1").match("[부록10]x") + + +def test_name_of_and_safe(): + assert tool.name_of("9-5", "9-5. 발파암") == "발파암" + assert tool.name_of("제2장", "제2장 소요재료 및 기계손료") == "소요재료 및 기계손료" + assert tool.safe("목재 운반/적재") == "목재_운반적재" + + +def test_head_round_trip(tmp_path): + path = tmp_path / "a.md" + path.write_text( + tool.head_text({"절": "9-5. 발파암", "상태": "대기"}) + "\n본문\n", encoding="utf-8" + ) + head = tool.read_head(path) + assert list(head) == list(tool.HEAD_KEYS) + assert head["절"] == "9-5. 발파암" and head["상태"] == "대기" and head["작성"] == "" + + +def test_forest_toc_counts(): + """산림 차례 — 14장 · 189절(PDF 3~12쪽).""" + pymupdf = pytest.importorskip("pymupdf") + book = tool.BOOKS["산림"] + with pymupdf.open(book["pdf"]) as doc: + entries = tool.toc_entries(doc, *book["toc"]) + assert sum(e.ident.startswith("제") for e in entries) == 14 + assert sum(e.ident.count("-") == 1 for e in entries) == 189 + + +def test_forest_headings_follow_page_spacing(): + """머리 글자 — 글자층은 빈칸을 잃음(「설계및수량」) · 쪽 그림대로 되살림 · 차례 오타(조림)는 안 따름.""" + pymupdf = pytest.importorskip("pymupdf") + with pymupdf.open(tool.BOOKS["산림"]["pdf"]) as doc: + assert tool.find_heading(doc, "1-2", 14)[2] == "1-2. 설계 및 수량" + assert tool.find_heading(doc, "12-3", 172)[2] == "12-3. 철근 현장가공 및 조립" + assert tool.find_heading(doc, "제1장", 13)[2] == "제1장 적용기준" + page, row, _ = tool.find_heading(doc, "9-6", 136) + assert (page, row) == (136, 0) # 쪽 맨 위 — 앞 절은 135 쪽에서 끝남 + + +def test_md_lines_drop_markup_keep_escaped_text(): + body = "\n".join( + [ + "## 9-5. 발파암", + "", + "(단위: ㎥당)", + "", + "| 구분 | 수량 |", + "|---|---|", + "| 보통인부 | 0.50 |", + r"| a\|b | 1,000
2 |", + "", + "> 【예시】 10^-7", + r"\- 원문 줄표", + "![그림 3](pic/9-05_1.png)", + ] + ) + text = " ".join(line for _, line in tool.md_lines(body)) + assert "#" not in text and "---" not in text and "
" not in text and "병합" not in text + assert "a|b" in text # 막음표 뗀 원문 글자 + assert "- 원문 줄표" in text and "그림" not in text and "pic" not in text + assert "10 -7" in text # 첨자 표시 ^ 는 뺌 + + +def test_compare_counts_numbers_and_chars_both_ways(): + pdf = [(135, "보통인부 0.50 1,000"), (135, "비고")] + same = tool.compare(pdf, tool.md_lines("| 보통인부 | 0.50 | 1,000 |\n비고")) + assert tool.verdict(same).startswith("통과") + bad = tool.compare(pdf, [(1, "보통인부 0.5 1000 비고 가")]) + assert set(bad["num_missing"]) == {"0.50", "1,000"} # 자릿수·쉼표 바꾸면 결손 + assert set(bad["num_extra"]) == {"0.5", "1000"} + assert bad["char_extra"]["가"] == 1 and bad["char_missing"][","] == 1 + assert tool.verdict(bad).startswith("불통") and bad["spots"] + + +def test_fingerprint_ignores_line_endings_and_tool_fields(): + text = "---\n상태: 확정\n기계검사: 통과\n확정지문:\n---\n본문\n" + same = text.replace("\n", "\r\n").replace("기계검사: 통과", "기계검사: 다시") + assert tool.fingerprint(text) == tool.fingerprint(same) + assert tool.fingerprint(text) != tool.fingerprint(text.replace("본문", "본문!")) + assert "확정지문: abc" in tool.set_head(text, "확정지문", "abc") diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 911b90fc..1af960b9 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -1,23 +1,27 @@ -"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시). +"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2). -틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개 -`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 · -덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) · +틀: 정본 = DB `master_base_price`(data = 지금 값 · seed = 초기값) · 원본 파일 `resources/data_*` 는 **주입 재료로 읽기만** · +[저장] 한 번 = {base_revision, edits, added, deleted} → 판이 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · +줄마다 changed_columns(초기값과 다른 칸)·is_added(관리자 추가 줄) · [초기값으로] = data := seed(추가 줄은 지움). 기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜. +DB 는 `helper_z01_fake_repo` 가짜로 — 저장소 SQL 흐름(잠금·되돌림)은 맨 아래 가짜 커넥션으로 잼. """ from __future__ import annotations +import asyncio import hashlib import json from decimal import Decimal from pathlib import Path +from types import SimpleNamespace import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from helper_z01_fake_repo import FakeRepo, install -from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo from Z01_MasterData import Z01_MasterData_Router as router_module ROOT = Path(__file__).resolve().parents[2] @@ -36,14 +40,12 @@ SOURCES = [ @pytest.fixture -def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - folder = tmp_path / "data_master_override" - monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder) - return folder +def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo: + return install(monkeypatch) @pytest.fixture -def client(store: Path) -> TestClient: +def client(fake: FakeRepo) -> TestClient: from common_util.common_util_auth import verify_session app = FastAPI() @@ -63,8 +65,20 @@ def _row(client: TestClient, kind: str, row_id: str) -> dict: return next(r for r in rows if r["@id"] == row_id) -def _put(client: TestClient, kind: str, row_id: str, values: dict): - return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values}) +def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None): + if base is None: + base = _get(client, kind, size=1)["revision"] + body = { + "base_revision": base, + "edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits], + "added": [{"data": d} for d in added], + "deleted": list(deleted), + } + return client.put(f"/api/master-data/base-prices/{kind}", json=body) + + +def _edit(client: TestClient, kind: str, row_id: str, column: str, value): + return _save(client, kind, edits=[(row_id, column, value)]) def _sha(path: Path) -> str: @@ -81,13 +95,15 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No for kind, (total, editable) in expected.items(): table = _get(client, kind, size=500) assert table["total"] == total and table["editable"] == editable, kind + assert table["status"] == "success" and table["revision"] == 0, kind ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]] assert len(ids) == len(set(ids)), kind - assert all("@overrides" not in r for r in table["rows"]), ( - kind - ) # 안 고친 줄엔 칸 자체가 없음 + # 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감 + assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"]) + assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")} columns = {c["key"] for c in table["columns"]} assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖 + assert not columns & {"changed_columns", "is_added"}, kind assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" @@ -116,8 +132,8 @@ def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를 "hourly_total_krw" in row["@formula"] and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"] ) - res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1}) - assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text + res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1) + assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"] def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: @@ -146,38 +162,23 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien assert checked >= 5 -def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None: +def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None: before = {p: _sha(p) for p in SOURCES} - res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + res = _save( + client, + "labor", + edits=[("labor_const/1001", "daily_wage_krw", 230000)], + base=0, + ) assert res.status_code == 200, res.text - row = res.json() - assert row["daily_wage_krw"] == 230000 - assert row["@overrides"] == { - "daily_wage_krw": { - "original": 215907, - "value": 230000, - "state": overrides.EDITED, - "current": 215907, - } - } - assert ( - _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000 - ) - assert ( - _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code - == 200 - ) - assert sorted(p.name for p in store.iterdir()) == [ - "labor.json", - "oil.json", - ] # kind 마다 한 파일 - saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) - entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"] - assert ( - entry["original"] == 215907 - and entry["source"] == "labor_const_2026-01-01.json" - and entry["by"] == 42 - ) + assert res.json() == {"status": "success", "revision": 1, "added": []} + row = _row(client, "labor", "labor_const/1001") + assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"] + stored = fake.by_key("labor")["labor_const/1001"] + assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907 + assert fake.saved_by == [42] # 누가 고쳤나 + assert _get(client, "labor", size=1)["revision"] == 1 + assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다 assert {p: _sha(p) for p in SOURCES} == before @@ -185,139 +186,113 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien base = _row(client, "machine", "0101-0007") operator = base["operator_occupation_code"] wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] - _put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000}) - _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000}) - _put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2}) + assert _edit( + client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000 + ).is_success + assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success + price = base["price_thousand_krw"] + assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success after = _row(client, "machine", "0101-0007") assert after["hourly_operator_krw"] > base["hourly_operator_krw"] assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"] assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2) - price = base["price_thousand_krw"] - assert after["@overrides"] == { - "price_thousand_krw": { - "original": price, - "value": price * 2, - "state": overrides.EDITED, - "current": price, - } - } # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜 + # 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜 + assert after["changed_columns"] == ["price_thousand_krw"] -def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None: - _put(client, "material", "10023392", {"price_krw": 30}) - reverted = _put(client, "material", "10023392", {"price_krw": None}).json() - assert reverted["price_krw"] == 22 and "@overrides" not in reverted - assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200 - saved = json.loads((store / "material.json").read_text(encoding="utf-8")) - assert saved["overrides"] == {} +def test_계산_칸은_DB_에_안_씀(client: TestClient, fake: FakeRepo) -> None: + """기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴.""" + _edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001) + data = fake.by_key("machine")["0101-0007"]["data"] + assert data["loss_coefficient_per_hour"] == 0.001 + assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data) -def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김( - client: TestClient, store: Path +def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None: + good = ("labor_const/1001", "daily_wage_krw", 230000) + for bad, status in ( + (("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸 + (("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값 + (("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄 + ): + res = _save(client, "labor", edits=[good, bad]) + assert res.status_code == status, (bad, res.text) + assert res.json()["status"] == "error" + res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"]) + assert res.status_code == 404 + assert fake.revisions == {} and fake.saved_by == [] + assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 + + +def test_낡은_판으로_저장하면_409( + client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch ) -> None: - store.mkdir() - (store / "labor.json").write_text( - json.dumps( - { - "schema_version": "1.0", - "kind": "labor", - "overrides": { - "labor_const/1001": { - "daily_wage_krw": { - "value": 199000, - "original": 200000, - "source": "labor_const_2025-07-01.json", - } - }, - "labor_const/9999": { - "daily_wage_krw": { - "value": 1, - "original": 1, - "source": "labor_const_2025-07-01.json", - } - }, - }, - } - ), - encoding="utf-8", - ) - # 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이 - row = _row(client, "labor", "labor_const/1001") - assert row["daily_wage_krw"] == 199000 - mark = row["@overrides"]["daily_wage_krw"] - assert mark == { - "original": 200000, - "value": 199000, - "state": overrides.SOURCE_CHANGED, - "current": 215907, - } - _put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000}) - listed = client.get("/api/master-data/overrides").json() - assert [ - (i["row_id"], i["state"]) for i in listed["items"] - ] == [ # 서버가 정렬 — 원본 바뀜이 맨 위 - ("labor_const/1001", overrides.SOURCE_CHANGED), - ("labor_const/9999", overrides.ORPHAN), - ("labor_const/1002", overrides.EDITED), - ] - first = listed["items"][0] - assert set(first) == { - "kind", - "row_id", - "row_label", - "column", - "column_label", - "original", - "current", - "value", - "state", - "unit", - } - assert first["row_label"] == "작업반장" and ( - first["original"], - first["current"], - first["value"], - ) == (200000, 215907, 199000) - assert listed["items"][1]["current"] is None - saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] - assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움 - # 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기 - kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json() - assert kept["@overrides"]["daily_wage_krw"] == { - "original": 215907, - "value": 199000, - "state": overrides.EDITED, - "current": 215907, - } - taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json() - assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken + assert _save( + client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0 + ).is_success + stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) + assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"] + assert _row(client, "labor", "labor_const/1002")["changed_columns"] == [] + # 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄) + + async def old_revision(kind: str) -> int: + return 0 + + monkeypatch.setattr(repo, "revision", old_revision) + raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) + assert raced.status_code == 409 and fake.revisions["labor"] == 1 -def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜( - client: TestClient, store: Path -) -> None: - blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None) - row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json() - assert row["@overrides"]["daily_wage_krw"] == { - "original": None, - "value": 150000, - "state": overrides.EDITED, - "current": None, - } - saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) - saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = ( - 140000 # 옛 판엔 값이 있었다고 치면 +def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None: + data = {"specification": "ZZ시험 규격, 특수", "unit": "개", "price_krw": 1234} + res = _save(client, "material", added=[data]) + assert res.status_code == 200, res.text + (key,) = res.json()["added"] + assert key.startswith("added/") and res.json()["revision"] == 1 + table = _get(client, "material", size=1, page=7000) + assert table["total"] == 7000 + row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례) + assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, []) + assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수" + assert _get(client, "material", q="ZZ시험")["total"] == 1 + # 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침 + assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success + assert _edit(client, "material", "10023392", "specification", "x").status_code == 400 + for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}): + assert _save(client, "material", added=[bad]).status_code == 400, bad + assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄 + gone = _save(client, "material", deleted=[key, "10023392"]) + assert gone.status_code == 200 + ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]} + assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998 + assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게) + + +def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None: + _edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000) + added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}]) + (key,) = added.json()["added"] + _save(client, "labor", deleted=["labor_const/1002"]) + res = client.post( + "/api/master-data/base-prices/labor/reset", + json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]}, ) - (store / "labor.json").write_text(json.dumps(saved), encoding="utf-8") - assert ( - _row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"] - == overrides.SOURCE_CHANGED + assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4} + back = _row(client, "labor", "labor_const/1001") + assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == [] + assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림 + assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움 + missing = client.post( + "/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]} ) + assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"] + empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []}) + assert empty.status_code == 422 # 통째로 되돌리기 막음 def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: rate = _get(client, "rate", size=1) - assert any("갱신" in line and "주인" in line for line in rate["notice"]) + assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"]) for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로 notice = _get(client, kind, size=1)["notice"] assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind @@ -325,16 +300,18 @@ def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: def test_막는_자리(client: TestClient) -> None: - assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404 - assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404 - assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400 - locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸 - assert locked.status_code == 400 and "공표 원문" in locked.text + assert _save(client, "wage", base=0).status_code == 404 + assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫 + locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸 + assert locked.status_code == 400 and "공표 원문" in locked.json()["message"] for bad in (True, -1, "220000", float("nan")): - body = json.dumps({"values": {"daily_wage_krw": bad}}).encode() + body = { + "base_revision": 0, + "edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}], + } res = client.put( - "/api/master-data/base-prices/labor/labor_const/1001", - content=body, + "/api/master-data/base-prices/labor", + content=json.dumps(body).encode(), headers={"content-type": "application/json"}, ) assert res.status_code in (400, 422), bad @@ -344,9 +321,20 @@ def test_막는_자리(client: TestClient) -> None: if r["@id"].startswith("rate_performance_guarantee_fee/brackets/") ) assert ( - _put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400 - ) # 그 줄엔 요율 칸이 없음 + _edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400 + ) # 그 줄엔 요율 칸 없음 assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56") + many = [("labor_const/1001", "daily_wage_krw", 1)] * 501 + assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지 + extra = client.put( + "/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}} + ) + assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함) + old = client.put( + "/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}} + ) + assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐 + assert client.get("/api/master-data/overrides").status_code == 404 def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None: @@ -364,61 +352,6 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None -def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None: - """브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음.""" - store.mkdir() - gone = { - f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}} - for i in range(3) - } - (store / "material.json").write_text( - json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}), - encoding="utf-8", - ) - _put(client, "material", "10023392", {"price_krw": 30}) - _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) - listed = client.get( - "/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"} - ).json() - assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN} - assert listed["counts"] == { - "material": {overrides.ORPHAN: 3, overrides.EDITED: 1}, - "labor": {overrides.EDITED: 1}, - } - paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json() - assert paged["total"] == 5 and len(paged["items"]) == 2 - everything = client.get("/api/master-data/overrides").json()[ - "items" - ] # labor 가 kind 차례로 앞이어도 - assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2 - res = client.post( - "/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN} - ) - assert res.status_code == 200 and res.json()["removed"] == 3 - left = client.get("/api/master-data/overrides").json() - assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례 - ("labor", overrides.EDITED), - ("material", overrides.EDITED), - ] - assert ( - client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code - == 422 - ) # 거름 없이 통째로 못 비움 - - -def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None: - rates = _get(client, "rate", size=500)["rows"] - fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")) - store.mkdir() - entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}} - (store / "rate.json").write_text( - json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8" - ) - items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"] - assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)] - assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음 - - SOURCE_KEYS = { "source_id", "name", @@ -476,12 +409,6 @@ def test_우리_판은_출처표_글자가_아니라_파일에서_읽음( assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로) -def test_모아_보기_항목에_단위(client: TestClient) -> None: - _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) - (item,) = client.get("/api/master-data/overrides").json()["items"] - assert item["unit"] == "원" and item["column_label"] == "일 노임" - - def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: """브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본.""" labels = json.loads( @@ -540,8 +467,8 @@ def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None: assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐 -def test_덮개_얹은_값으로_세움(client: TestClient) -> None: - _put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000}) +def test_고친_값으로_세움(client: TestClient) -> None: + _edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000) top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0] assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000 @@ -551,54 +478,107 @@ def test_sort_가_없으면_원본_차례(client: TestClient) -> None: assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" -def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None: - """브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격). - - 값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌. - """ - saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json() - assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED - entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] - meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"] - assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만 - assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음 - assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨 - _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}) - oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"] - assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"] - entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름" - (store / "labor.json").write_text( - json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8" - ) - mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"] - assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜 - # 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌) - assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published" - assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published" - listed = client.get("/api/master-data/overrides").json() - top = listed["items"][0] - assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게 - assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘 - assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED - assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED) +def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None: + """카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄.""" + data = { + "machine_name": "시험 굴삭기", + "price_thousand_krw": 100000, + "loss_coefficient_per_hour": 0.0001, + } + (key,) = _save(client, "machine", added=[data]).json()["added"] + row = _get(client, "machine", size=1, page=614)["rows"][0] + assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000) + assert row["hourly_total_krw"] is None and row["hourly_note"] + assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸 -def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None: - _put(client, "machine", "0101-0007", {"price_thousand_krw": 80000}) - meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][ - "0101-0007" - ]["price_thousand_krw"]["meaning"] - assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7" - assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")] - store.mkdir( - exist_ok=True - ) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음) - (store / "material.json").write_text( - json.dumps( - {"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}} - ), - encoding="utf-8", - ) - assert ( - _row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED - ) +# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ────────────────────────────── + + +class _Cursor: + def __init__(self, db: SimpleNamespace) -> None: + self.db = db + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql: str, params=()) -> None: + self.db.sql.append(" ".join(sql.split())) + + async def fetchone(self): + return (self.db.revision,) + + async def fetchall(self): + return [(key,) for key in self.db.keys] + + +class _Connection: + def __init__(self, db: SimpleNamespace) -> None: + self.db = db + + def cursor(self, *_): + return _Cursor(self.db) + + async def begin(self) -> None: + self.db.log.append("begin") + + async def commit(self) -> None: + self.db.log.append("commit") + + async def rollback(self) -> None: + self.db.log.append("rollback") + + +class _Pool: + def __init__(self, db: SimpleNamespace) -> None: + self.db = db + + def acquire(self): + return self + + async def __aenter__(self): + return _Connection(self.db) + + async def __aexit__(self, *_): + return False + + +def _writes(db: SimpleNamespace) -> list[str]: + return [ + s + for s in db.sql + if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price")) + ] + + +def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None: + db = SimpleNamespace(sql=[], log=[], revision=4, keys=[]) + monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) + with pytest.raises(repo.RevisionConflict): + asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7)) + assert db.log == ["begin", "rollback"] and not _writes(db) + db.sql.clear() + db.log.clear() + assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5 + assert db.log == ["begin", "commit"] + lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE")) + bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision")) + writes = [db.sql.index(s) for s in _writes(db)] + assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1 + assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만 + + +def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None: + db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"]) + monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) + with pytest.raises(repo.UnknownRows): + asyncio.run(repo.reset("labor", ["a", "없음"], 7)) + assert db.log == ["begin", "rollback"] and not _writes(db) + db.sql.clear() + db.log.clear() + assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3 + assert db.log == ["begin", "commit"] + assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "] diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 88b17725..e0bf710b 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -19,9 +19,9 @@ ROOT = Path(__file__).resolve().parents[2] import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from helper_z01_fake_repo import install from Z01_MasterData import Z01_MasterData_BasePrices as base_prices -from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Router as router_module from Z01_MasterData import Z01_MasterData_WorkItems as work_items @@ -40,10 +40,10 @@ NEW_KINDS = ( @pytest.fixture -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: from common_util.common_util_auth import verify_session - monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") + install(monkeypatch) # DB 가짜 — 파일 원본을 초기값으로 주입한 모양 app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} @@ -69,13 +69,13 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None table = _get(client, kind, size=500) assert table["total"] == len(table["rows"]) > 0, kind ids = _ids(table) - assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음 + assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 (kind, row_key) 한 줄이 둘을 덮음 assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글 assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())} assert {table["locked"][c] for c in axis if c in table["locked"]} == { - "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" + "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음" }, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함 assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로) @@ -165,35 +165,39 @@ def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None: ) -def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None: - res = client.put( - "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", - json={"values": {"m3_per_m2": 0.1}}, +def _save(client: TestClient, kind: str, row_key: str, column: str, value): + revision = _get(client, kind, size=1)["revision"] + return client.put( + f"/api/master-data/base-prices/{kind}", + json={ + "base_revision": revision, + "edits": [{"row_key": row_key, "column": column, "value": value}], + }, ) + + +def test_고치기도_같은_길(client: TestClient) -> None: + res = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "m3_per_m2", 0.1) assert res.status_code == 200, res.text - row = res.json() - assert row["m3_per_m2"] == 0.1 - assert row["@overrides"]["m3_per_m2"]["original"] == 0.09 - assert (tmp_path / "data_master_override" / "stone_kind.json").is_file() - listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json() - assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"] - blocked = client.put( - "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", - json={"values": {"stone_kind": "딴 돌"}}, - ) + rows = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]} + row = rows["wedge_stone_m3_per_m2/깬잡석/25"] + assert row["m3_per_m2"] == 0.1 and row["changed_columns"] == ["m3_per_m2"] + assert [k for k, r in rows.items() if r["changed_columns"]] == [ + "wedge_stone_m3_per_m2/깬잡석/25" + ] + blocked = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "stone_kind", "딴 돌") assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침 - assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄 + assert not {"@axis", "@source", "@data"} & set(row) # 안쪽 칸은 안 내보냄 # 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함 - axis_edit = client.put( - "/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood_material_pct/2", - json={"values": {"reuse_count": 3}}, + # ⚠ 주입 data 에 `@axis` 가 없어도 막힘(축은 파일 원본에서 읽음 · 가짜 주입은 '@' 칸을 뺌) + axis_edit = _save( + client, "formwork_reuse", "reuse_ratio_pct/plywood_material_pct/2", "reuse_count", 3 ) assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text - value_edit = client.put( - "/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조", - json={"values": {"reuse_count": 3}}, - ) - assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3 + value_edit = _save(client, "formwork_reuse", "reuse_by_class/복잡한 구조", "reuse_count", 3) + assert value_edit.status_code == 200 + reuse = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]} + assert reuse["reuse_by_class/복잡한 구조"]["reuse_count"] == 3 def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None: diff --git a/resources/tester/test_z01_master_draft.py b/resources/tester/test_z01_master_draft.py new file mode 100644 index 00000000..30135a5f --- /dev/null +++ b/resources/tester/test_z01_master_draft.py @@ -0,0 +1,184 @@ +"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18). + + ① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄 + ② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움 + ③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기) + ④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음 + ⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order) + ⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422 +TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; + +// 브라우저 sessionStorage 대역 +const store = new Map(); +globalThis.sessionStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; +const { + BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload, +} = await import("./Z01_MasterData_UI_Draft.js"); + +// ① 기초단가 +const base = new BaseDraft("base:labor", 7); +base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907); +base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임 +base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2); +base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐 +base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4); +base.toggleDelete("labor_const/9999"); +const added = base.addRow(); +base.setAdded(added.tmp, "occupation_name", "새 직종"); +base.setAdded(added.tmp, "daily_wage_krw", 300000); +base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌 +const payload = base.payload(); +const count = base.count; +const stored = JSON.parse(store.get("z01-draft:base:labor")); + +// ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움 +const again = new BaseDraft("base:labor", 7); +const restored = again.payload(); +const newer = new BaseDraft("base:labor", 8); +const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") }; +const toggled = new BaseDraft("base:oil", 1); +toggled.toggleDelete("x"); +toggled.toggleDelete("x"); +const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") }; + +// ③④⑤ 공종 +const row = (id, variant, extra = {}) => ({ + id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부", + resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null, + group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1", + // GET 전용 칸 — PUT 에 실리면 422(extra=forbid) + sort_order: id, is_added: false, changed_columns: [], ...extra, +}); +const detail = { + item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목", + path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1, + basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 }, + rows: [row(1, "갑"), row(2, "을"), row(3, "갑")], + source: [], notes: [], +}; +const body = freshBody(detail); +const clean = isDirty(detail, body); +body.rows[0].amount = 0.7; +body.rows[1].deleted = true; +addRow(body, "을", "new-1"); +body.item.status = "reviewed"; +const sent = workItemPayload(detail.item.revision, body); + +writeFileSync(process.argv[2], JSON.stringify({ + payload, count, stored, + restored, dropped, emptied, + clean, + dirty: isDirty(detail, body), + sent, + changed: [...changedFields(body.rows[0], detail.rows)], + addedRow: changedFields(body.rows[2], detail.rows), + untouched: [...changedFields(body.rows[3], detail.rows)], + groups: groupByVariant(body.rows), + freshUntouched: freshBody(detail).rows[0].amount, +})); +""" + + +def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(SOURCE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--ignoreConfig", + "--noCheck", + "--noResolve", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "output.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + got = json.loads(result.read_text(encoding="utf-8")) + + # ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만 + assert got["payload"] == { + "base_revision": 7, + "edits": [ + {"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000}, + ], + "added": [{"data": {"occupation_name": "새 직종"}}], + "deleted": ["labor_const/9999"], + } + # 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1 + assert got["count"] == 4 + assert got["stored"]["revision"] == 7 + # ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐 + assert got["restored"] == got["payload"] + assert got["dropped"] == {"count": 0, "left": False} + assert got["emptied"] == {"count": 0, "left": False} + # ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림 + assert got["clean"] is False + assert got["dirty"] is True + sent = got["sent"] + assert sent["base_revision"] == 3 + assert sent["item"] == { + "name": "벌목", + "basis_quantity": 1, + "basis_unit": "ha", + "status": "reviewed", + } + assert [r.get("id") for r in sent["rows"]] == [1, None, 3] + # ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자 + fields = { + "variant", + "resource_kind", + "resource_key", + "resource_name", + "resource_spec", + "amount", + "amount_unit", + "alternative_amount", + "group_ratio_pct", + "raw_cell", + "pum_table_id", + } + assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}] + assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == "" + # ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림 + assert got["changed"] == ["amount"] + assert got["addedRow"] == "added" + assert got["untouched"] == [] + assert got["freshUntouched"] == 0.5 + # ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로 + assert got["groups"] == [ + {"variant": "갑", "indexes": [0, 3]}, + {"variant": "을", "indexes": [1, 2]}, + ] diff --git a/resources/tester/test_z01_work_item_edit.py b/resources/tester/test_z01_work_item_edit.py new file mode 100644 index 00000000..040b3475 --- /dev/null +++ b/resources/tester/test_z01_work_item_edit.py @@ -0,0 +1,235 @@ +"""Z01 공종 편집 — 규칙(순수 함수) · 요청 모델 · 저장 흐름(409·422·전부 아니면 전무) · 등록. + +(2026-09-18 브레인 PLAN 1-0 계약 · sub_laptop_3) DB 없이 — 저장 흐름은 가짜 커넥션·저장소로 훑음. +""" + +from __future__ import annotations + +import importlib +from contextlib import asynccontextmanager +from decimal import Decimal + +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from common_util.common_util_auth import require_system_admin, verify_session +from Z01_MasterData import Z01_MasterData_Router_WorkItems as router_module +from Z01_MasterData import Z01_MasterData_WorkItems as work_items + +SEED_ROW = {"resource_kind": "labor", "resource_key": "L-001", "amount": 1.5, "sort_order": 0} + + +def _row(row_id, seed=SEED_ROW, **values): + base = {"id": row_id, "variant": "", "resource_kind": "labor", "resource_key": "L-001"} + return {**base, "amount": Decimal("1.500000"), "sort_order": 0, "seed": seed, **values} + + +# ── 규칙 ── + + +def test_고친_칸은_초기값과_값으로_비교함() -> None: + assert work_items.changed_columns(_row(1), SEED_ROW, work_items.ROW_FIELDS) == [] + changed = _row(1, amount=Decimal("2")) + assert work_items.changed_columns(changed, SEED_ROW, work_items.ROW_FIELDS) == ["amount"] + assert work_items.changed_columns(_row(1, seed=None), None, work_items.ROW_FIELDS) == [] + + +def test_검사는_남의_줄_겹친_줄_없는_자원을_막음() -> None: + rows = [ + {"id": 1, "resource_key": "L-001"}, + {"id": 1, "resource_key": None}, + {"id": 99, "resource_key": None}, + {"id": None, "resource_key": "없는열쇠"}, + ] + errors = work_items.check_rows(rows, existing_ids={1, 2}, known_keys={"L-001"}) + assert len(errors) == 3 + assert "두 번" in errors[0] and "id 99" in errors[1] and "없는열쇠" in errors[2] + assert work_items.check_rows(rows[:1], {1}, {"L-001"}) == [] + + +def test_전체_줄_보내기는_바뀐_줄만_쓰고_빠진_줄은_숨기거나_지움() -> None: + existing = [_row(1), _row(2, sort_order=1), _row(3, seed=None, sort_order=2)] + same = {f: existing[0].get(f) for f in work_items.ROW_FIELDS} | {"id": 1, "amount": 1.5} + new = {"id": None, "resource_kind": "material", "resource_key": None, "amount": None} + plan = work_items.plan_rows(existing, [same, new]) + assert plan["updates"] == [] # 1.500000 = 1.5 · 순서도 그대로 + assert plan["inserts"][0]["resource_kind"] == "material" + assert plan["inserts"][0]["sort_order"] == 1 + assert plan["hide"] == [2] # 초기값 줄 — 숨김(되살릴 수 있게) + assert plan["drop"] == [3] # 관리자 추가 줄 — 지움 + + moved = work_items.plan_rows(existing[:2], [{**same, "id": 2}, same]) + assert [u["id"] for u in moved["updates"]] == [2, 1] # 순서만 바뀌어도 sort_order 를 씀 + + +def test_초기값으로는_seed_칸만_살리고_추가_줄은_지움() -> None: + head = {"id": 7, "name": "고친 이름", "seed": {"name": "원 이름", "status": "partial"}} + rows = [_row(1, amount=Decimal("9")), _row(2, is_deleted=1), _row(3, seed=None)] + plan = work_items.reset_plan(head, rows) + assert plan["head"] == {"name": "원 이름", "status": "partial"} + assert [u["id"] for u in plan["updates"]] == [1, 2] + assert plan["updates"][0]["amount"] == 1.5 + assert plan["drop"] == [3] and plan["inserts"] == [] and plan["hide"] == [] + + +def test_상세는_seed_를_안_내보내고_고친_칸만_알림() -> None: + head = {"id": 7, "work_item_key": "FW-00001", "name": "새", "status": "partial"} + head |= {"seed": {"name": "옛"}, "source": [{"pum_table_id": "T1"}], "notes": ["[주] 1"]} + out = work_items.detail(head, [_row(1), _row(2, seed=None)], revision=4) + assert out["item"]["revision"] == 4 and out["item"]["changed_columns"] == ["name"] + assert "seed" not in out["item"] and "id" not in out["item"] + assert out["source"] == [{"pum_table_id": "T1"}] and out["notes"] == ["[주] 1"] + assert [r["is_added"] for r in out["rows"]] == [False, True] + assert out["rows"][0]["id"] == 1 and "seed" not in out["rows"][0] + + +# ── 요청 모델 ── + + +def _body(**row): + item = {"name": "공종", "status": "reviewed"} + return {"base_revision": 3, "item": item, "rows": [{"resource_kind": "labor", **row}]} + + +@pytest.mark.parametrize( + "row", + [{"amount": -1}, {"resource_key": ""}, {"resource_kind": "oil"}, {"unknown": 1}], +) +def test_요청_모델은_음수_빈열쇠_모르는_종류_모르는_칸을_막음(row) -> None: + with pytest.raises(ValidationError): + router_module.WorkItemSave.model_validate(_body(**row)) + + +# ── 저장 흐름(가짜 커넥션) ── + + +class _Connection: + def __init__(self) -> None: + self.log: list[str] = [] + + async def begin(self) -> None: + self.log.append("begin") + + async def commit(self) -> None: + self.log.append("commit") + + async def rollback(self) -> None: + self.log.append("rollback") + + +@pytest.fixture +def client(monkeypatch): + connection = _Connection() + + class _Pool: + @asynccontextmanager + async def acquire(self): + yield connection + + head = {"id": 7, "work_item_key": "FW-00001", "name": "공종", "status": "partial"} + head |= {"seed": {"name": "공종"}, "source": [], "notes": []} + + async def fetch_item(_c, key, lock=False): + return dict(head) if key == "FW-00001" else None + + async def fetch_rows(_c, _item_id, with_deleted=False): + return [_row(1)] + + async def revision(_c, _scope, lock=False): + return 3 + + async def known_resource_keys(_c, keys): + return keys & {"L-001"} + + async def write(_c, *args): + connection.log.append("write") + + async def bump_revision(_c, _scope): + connection.log.append("bump") + + async def list_items(_c, **picked): + connection.log.append(picked["status"]) + return {"items": [], "total": 2, "counts": {"partial": 2}} + + monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool()) + for name, fake in { + "list_items": list_items, + "fetch_item": fetch_item, + "fetch_rows": fetch_rows, + "revision": revision, + "known_resource_keys": known_resource_keys, + "write": write, + "bump_revision": bump_revision, + }.items(): + monkeypatch.setattr(router_module.repo, name, fake) + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42} + test_client = TestClient(app) + test_client.log = connection.log + return test_client + + +URL = "/api/master-data/work-items/FW-00001" + + +def test_낡은_revision_은_409_이고_아무것도_안_씀(client) -> None: + response = client.put(URL, json={**_body(id=1, resource_key="L-001"), "base_revision": 2}) + assert response.status_code == 409 + assert client.log == ["begin", "rollback"] + + +def test_없는_자원_열쇠는_422_이고_아무것도_안_씀(client) -> None: + response = client.put(URL, json=_body(resource_key="없는열쇠")) + assert response.status_code == 422 and "없는열쇠" in response.json()["detail"] + assert client.log == ["begin", "rollback"] + + +def test_맞는_저장은_쓰고_revision_올리고_새_상세를_줌(client) -> None: + response = client.put(URL, json=_body(id=1, resource_key="L-001", amount=2)) + assert response.status_code == 200, response.text + assert client.log == ["begin", "write", "bump", "commit"] + assert response.json()["status"] == "success" and response.json()["item"]["revision"] == 3 + + +def test_목록은_상태별_개수를_다_채우고_모르는_상태는_막음(client) -> None: + body = client.get("/api/master-data/work-items?status=").json() + assert body["counts"] == {s: 2 if s == "partial" else 0 for s in work_items.STATUSES} + assert client.get("/api/master-data/work-items?status=needs_input").status_code == 200 + assert client.get("/api/master-data/work-items?status=bogus").status_code == 422 + assert client.log == ["", "needs_input"] + + +def test_초기값으로와_없는_공종(client) -> None: + assert client.post(URL + "/reset").status_code == 200 + assert client.log == ["begin", "write", "bump", "commit"] + assert client.get("/api/master-data/work-items/FW-99999").status_code == 404 + + +# ── 등록 ── + + +def _calls(dependant) -> set: + found = set() + for sub in dependant.dependencies: + found.add(sub.call) + found |= _calls(sub) + return found + + +def test_공종_편집_경로는_시스템_관리자만() -> None: + app = importlib.import_module("main").app + routes = {(r.path, m) for r in app.routes if isinstance(r, APIRoute) for m in r.methods} + wanted = { + ("/api/master-data/work-items", "GET"), + ("/api/master-data/work-items/{key}", "GET"), + ("/api/master-data/work-items/{key}", "PUT"), + ("/api/master-data/work-items/{key}/reset", "POST"), + } + assert wanted <= routes + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith("/api/master-data/work-items"): + assert {verify_session, require_system_admin} <= _calls(route.dependant) diff --git a/resources/tester/test_z01_work_items.py b/resources/tester/test_z01_work_items.py index 5bce9ff6..ec12ea20 100644 --- a/resources/tester/test_z01_work_items.py +++ b/resources/tester/test_z01_work_items.py @@ -12,17 +12,17 @@ from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from helper_z01_fake_repo import install -from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Router as router_module from Z01_MasterData import Z01_MasterData_WorkItems as work_items @pytest.fixture -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: from common_util.common_util_auth import verify_session - monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") + install(monkeypatch) # 기초단가 표 목록·줄 수가 DB 를 읽음 — 가짜로 app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} @@ -131,10 +131,15 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음 res = client.put( - "/api/master-data/base-prices/work_item_const/CW-00002%2301", - json={"values": {"name": "바꿈"}}, + "/api/master-data/base-prices/work_item_const", + json={ + "base_revision": 0, + "edits": [{"row_key": "CW-00002#01", "column": "name", "value": "바꿈"}], + }, ) - assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"] + assert ( + res.status_code == 400 and "고칠 수 없는 표" in res.json()["message"] + ) # 공종은 /work-items 몫 def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None: diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index ccc0850a..fd7cab5e 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -142,68 +142,105 @@ export const ui_locales_b3 = { Z01_MasterData_Title: ["마스터 데이터", "Master Data"], Z01_MasterData_Tree: ["마스터 트리", "Master tree"], /* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */ - Z01_MasterData_List_Kind: ["표", "Table"], - Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ - "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", - "Editable — click, type, Enter to save (Esc cancels)", + "고칠 수 있는 칸 — 눌러 고치고 Enter (Esc 취소) · [저장]을 눌러야 반영", + "Editable — click, type, Enter (Esc cancels) · applied on [Save]", ], Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], - Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], - Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], - Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], Z01_MasterData_Edit_Invalid: [ "「{value}」 를 수로 못 읽음", "Cannot read 「{value}」 as a number", ], - Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"], - Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], - Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], + Z01_MasterData_Edit_NotSaved: ["⚠ 안 들어감 — {value}", "⚠ Not applied — {value}"], Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], Z01_MasterData_Formula: ["산출근거", "Basis"], - Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], - Z01_MasterData_State_SourceChanged: [ - "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", - "⚠ Source changed after edit — edited value still used · new source {value}", + /* --- Z01 고치기 — [저장] 한 번에 보냄(자동저장 금지 · PLAN 1-0) --- */ + Z01_MasterData_Save: ["저장", "Save"], + Z01_MasterData_Discard: ["고친 것 버리기", "Discard edits"], + Z01_MasterData_AddRow: ["줄 추가", "Add row"], + Z01_MasterData_DeleteRow: ["줄 삭제", "Delete row"], + Z01_MasterData_UndeleteRow: ["삭제 취소", "Undo delete"], + Z01_MasterData_Reset: ["초기값으로", "Reset to initial"], + Z01_MasterData_ResetConfirm: [ + "고른 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", + "Reset the picked row to its initial value — saved at once (admin-added rows are removed)", ], - Z01_MasterData_State_MeaningChanged: [ - "⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)", - "⛔ Edited — the row now means something else (same code, different item)", + Z01_MasterData_ResetBlocked: [ + "저장 안 한 고침이 있음 — 먼저 [저장]하거나 [고친 것 버리기]", + "Unsaved edits — save or discard them first", ], - Z01_MasterData_State_MeaningMissing: [ - "⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그", - "⛔ Server sent no before/after row names — bug", + Z01_MasterData_ResetDone: ["초기값으로 되돌렸음", "Reset to initial value"], + Z01_MasterData_Saved: ["저장했음", "Saved"], + Z01_MasterData_Unsaved: ["저장 안 한 고침이 있음", "There are unsaved edits"], + Z01_MasterData_Draft_Edits: ["고친 칸", "Edited cells"], + Z01_MasterData_Draft_Added: ["추가 줄", "Added rows"], + Z01_MasterData_Draft_Deleted: ["삭제 줄", "Deleted rows"], + Z01_MasterData_Draft_Pending: ["[저장]해야 반영됨", "applied on [Save]"], + Z01_MasterData_Draft_None: ["고친 것 없음", "No edits"], + Z01_MasterData_Draft_Stale: [ + "⚠ 다른 곳에서 먼저 저장됨 — 이 초안은 저장 못 함 · [고친 것 버리기]로 새로 읽을 것", + "⚠ Saved elsewhere first — this draft cannot be saved · discard to reload", ], - Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"], - Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"], - Z01_MasterData_Edit_DropOnMeaning: [ - "고친 값 버리고 지금 줄 값 {value} 쓰기", - "Drop the edit and use the current row value {value}", + Z01_MasterData_Cell_Draft: ["저장 안 한 고침 — {value}", "Unsaved edit — {value}"], + Z01_MasterData_Cell_Changed: ["초기값과 다름(저장된 고침)", "Differs from the initial value"], + Z01_MasterData_Row_Added: ["관리자가 추가한 줄", "Row added by an admin"], + Z01_MasterData_Row_New: ["새 줄 — 저장 전", "New row — not saved yet"], + /* --- Z01 공종 편집 (PLAN 1-4) --- */ + Z01_WorkItem_Title: ["공종 편집", "Work items"], + Z01_WorkItem_Axis_Forest: ["산림 공종", "Forest"], + Z01_WorkItem_Axis_Const: ["건설 공종", "Construction"], + Z01_WorkItem_Pick: ["왼쪽 목록에서 공종을 고를 것", "Pick a work item from the list"], + Z01_WorkItem_Status: ["상태", "Status"], + Z01_WorkItem_Status_All: ["전부", "All"], + Z01_WorkItem_Status_NeedsInput: ["입력 필요", "Needs input"], + Z01_WorkItem_Status_Partial: ["일부 못 맞춤", "Partly unmatched"], + Z01_WorkItem_Status_AutoOk: ["자동 완료", "Auto OK"], + Z01_WorkItem_Status_NotItem: ["공종 아님", "Not a work item"], + Z01_WorkItem_Status_Reviewed: ["검수 완료", "Reviewed"], + Z01_WorkItem_Review: ["검수 완료", "Mark reviewed"], + Z01_WorkItem_Dirty: [ + "저장 안 한 고침 있음 — [저장]해야 반영됨", + "Unsaved edits — applied on [Save]", ], - Z01_MasterData_Edit_MeaningConfirm: [ - "줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?", - "This row now means something else. Dropping the edit uses the current row's value. Continue?", + Z01_WorkItem_ResetConfirm: [ + "이 공종의 머리·구성 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", + "Reset this work item's header and rows — saved at once (admin-added rows are removed)", ], - Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"], - Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"], - Z01_MasterData_State_Orphan: [ - "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", - "⚠ Orphan — the row disappeared on update", - ], - Z01_MasterData_State_Missing: [ - "⚠ 서버가 판정(state)을 안 줌 — 버그", - "⚠ Server sent no state — bug", - ], - Z01_MasterData_List_State: ["상태", "State"], - Z01_MasterData_List_Row: ["줄", "Row"], - Z01_MasterData_List_Column: ["칸", "Column"], - Z01_MasterData_List_Value: ["고친 값", "Edited value"], - Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], - Z01_MasterData_List_Current: ["지금 원본", "Current source"], - Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], - Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], - Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], + Z01_WorkItem_Key: ["열쇠", "Key"], + Z01_WorkItem_Number: ["번호", "Number"], + Z01_WorkItem_Parent: ["상위 공종", "Parent"], + Z01_WorkItem_Edition: ["판", "Edition"], + Z01_WorkItem_Name: ["이름", "Name"], + Z01_WorkItem_BasisQuantity: ["기준 수량", "Basis quantity"], + Z01_WorkItem_BasisUnit: ["기준 단위", "Basis unit"], + Z01_WorkItem_Rows: ["구성 줄", "Resource rows"], + Z01_WorkItem_NoVariant: ["갈래 없음", "No variant"], + Z01_WorkItem_VariantName: ["새 갈래 이름", "New variant name"], + Z01_WorkItem_AddVariant: ["갈래 추가", "Add variant"], + Z01_WorkItem_Col_Kind: ["종류", "Kind"], + Z01_WorkItem_Col_Resource: ["자원", "Resource"], + Z01_WorkItem_Col_Spec: ["규격", "Spec"], + Z01_WorkItem_Col_Key: ["기초단가 열쇠", "Base price key"], + Z01_WorkItem_Col_Amount: ["수량", "Amount"], + Z01_WorkItem_Col_Unit: ["단위", "Unit"], + Z01_WorkItem_Col_Alt: ["대체 수량", "Alternative"], + Z01_WorkItem_Col_Ratio: ["묶음 비율 %", "Group ratio %"], + Z01_WorkItem_Col_Raw: ["원문 글자", "Source text"], + Z01_WorkItem_Col_Table: ["품셈 표", "Table"], + Z01_WorkItem_Col_Delete: ["", ""], + Z01_WorkItem_Kind_Labor: ["노무", "Labor"], + Z01_WorkItem_Kind_Machine: ["기계", "Machine"], + Z01_WorkItem_Kind_Material: ["자재", "Material"], + Z01_WorkItem_Kind_Etc: ["기타", "Other"], + Z01_WorkItem_Unmatched: ["⚠ 못 맞춤", "⚠ Unmatched"], + Z01_WorkItem_Find: ["찾기", "Find"], + Z01_WorkItem_FindTitle: ["자원 찾기 — 기초단가에서 골라 이음", "Find resource in base prices"], + Z01_WorkItem_FindClose: ["닫기", "Close"], + Z01_WorkItem_FindNone: ["찾은 줄 없음", "Nothing found"], + Z01_WorkItem_Source: ["품셈 원문 (읽기 전용)", "Standard source (read-only)"], + Z01_WorkItem_NoSource: ["원문 표 없음", "No source table"], + Z01_WorkItem_Notes: ["[주]", "[Notes]"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],