diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 1fffe9fd..b4da3fc7 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -2,12 +2,17 @@ * Z01_MasterData_Api_Fetch.ts * 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만 * - * 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄 - * (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐). + * 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; +import type { + MasterColumn, + OverrideState, + ServedKind, + SortOrder, + TableMeta, +} from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -33,16 +38,11 @@ export interface MasterGroup { files: MasterFile[]; } -/** - * 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴. - * 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음. - */ +/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */ export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; - /** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */ - revision?: number; } export interface RowsQuery { @@ -65,34 +65,17 @@ 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().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) { - // 검사 실패(422)는 detail 이 목록으로 옴 — 글자로 펴서 보임(「[object Object]」 금지). - const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); - throw new ApiError(detail ?? "Request failed", response.status); - } + const data = (await response.json()) as { detail?: string } & T; + if (!response.ok) throw new Error(data.detail ?? "Request failed"); return data; } -const send = (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), @@ -110,142 +93,37 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise< return request(`/master-data/base-prices/${kind}?${params}`); } -/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */ -export interface BaseSave { - base_revision: number; - edits: { row_key: string; column: string; value: unknown }[]; - added: { data: Record }[]; - deleted: string[]; +export interface OverrideItem { + kind: BasePriceKind; + row_id: string; + row_label: string; + column: string; + column_label: string; + original: unknown; + current?: unknown; + value: unknown; + unit?: string; + state: OverrideState; } -export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise { - return send("PUT", `/master-data/base-prices/${kind}`, body); +/** 고친 것 모아 보기 — 서버가 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}`); } -/** 고른 줄을 초기값(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), +/** 칸 고치기 — 값 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 }), }); - 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 0ccb5576..9a3d7342 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -1,20 +1,16 @@ """Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약). 표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만) -줄 수준: `@id`(자료 제 열쇠) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자 추가 줄) · `@formula`(계산값 줄) +줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄) `@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code · 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]`(원본 값 그대로 · 열 이름은 이름표 것) · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) -정본 = DB `master_base_price`(2026-09-18 PLAN 1-0 · `Z01_MasterData_Repository_BasePrices`) — 파일 원본(`base_rows`)은 - 초기값 1회 주입(`Z01_MasterData_Seed`)의 재료 · 공종 축 둘만 아직 파일 읽기. +원본 파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`). """ from __future__ import annotations -import asyncio import json -import math import re -import uuid from datetime import date from decimal import Decimal from functools import lru_cache @@ -23,7 +19,7 @@ from typing import Any from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables -from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo +from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Tables as tables from Z01_MasterData import Z01_MasterData_WorkItems as work_items @@ -32,7 +28,7 @@ KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work SOURCES_DIR = ( tables.RESOURCES / "data_master_sources" ) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지 -_KEY = "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음" +_KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" _SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜" _RATE_EDITABLE = ( "rate_percent", @@ -43,14 +39,14 @@ _RATE_EDITABLE = ( "manager_thresholds.default_estimated_amount_krw", "manager_thresholds.civil_main_work_estimated_amount_krw", ) -#: ⚠ **지금 상태를 먼저** — 고친 값을 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」). +#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」). #: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠. _SCOPE_NOTICE = ( "⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음." " 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜" "(프로젝트는 만들 때 뜬 사본을 봄)" ) -_RATE_NOTICE = "법이 정한 값 — 고시가 바뀔 때만 고칠 것(구간 칸은 규칙이라 못 고침)" +_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" def _source(file_id: str) -> Path: @@ -281,7 +277,7 @@ def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: def base_rows(kind: str) -> list[dict[str, Any]]: - """파일 원본 줄 — 초기값 주입의 재료 · 기계는 입력 칸까지(계산 칸은 읽을 때 셈).""" + """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" if kind == "machine": doc, name = _doc("mach_base") return machine.base_rows(name, edition_of(doc)) @@ -294,67 +290,33 @@ def base_rows(kind: str) -> list[dict[str, Any]]: return _BUILDERS[kind]() -def _machine_inputs(labor: list[dict[str, Any]], oil: list[dict[str, Any]]) -> machine.Inputs: - """기계가 끌어 쓰는 밑값 — DB 의 노임(건설)·전국평균 유가 + 그 판들의 기준일(빈 값 줄은 뺌).""" - labor = [r for r in labor if r.get("source") == "labor_const" and r.get("occupation_code")] +def _machine_inputs() -> machine.Inputs: + """기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일.""" + labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"] oil = [ - r - for r in oil - if r["@id"] == f"national_average/{r.get('fuel')}" and r.get("price_krw_per_l") is not None + r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}" ] return machine.Inputs( wages={ str(r["occupation_code"]): None - if r.get("daily_wage_krw") is None + if r["daily_wage_krw"] is None else Decimal(str(r["daily_wage_krw"])) for r in labor }, - wage_date=labor[0].get("effective_date") if labor else None, + wage_date=labor[0]["effective_date"] if labor else None, oil={r["fuel"]: Decimal(str(r["price_krw_per_l"])) for r in oil}, - oil_dates={r["fuel"]: r.get("date") or r.get("effective_date") for r in oil}, + oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, operating_date=edition_of(_doc("pum_const")[0]), ) -def same(a: Any, b: Any) -> bool: - """값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음.""" - if a is None or b is None: - return a is None and b is None - if isinstance(a, (int, float)) and isinstance(b, (int, float)): - return Decimal(str(a)) == Decimal(str(b)) - return a == b - - -def _row(record: dict[str, Any]) -> dict[str, Any]: - """DB 한 줄 → 표 줄. `@data` = 저장된 값 그대로(쓸 때 여기에 고친 칸만 얹음 · 기계 계산 칸이 섞이지 않게).""" - data, seed = record["data"], record["seed"] - changed = [] - if seed is not None: - changed = [ - c - for c in dict.fromkeys([*seed, *data]) - if not c.startswith("@") and not same(data.get(c), seed.get(c)) - ] - row = {"@id": record["row_key"], **data} - row.update( - {"@id": record["row_key"], "@data": data, "@added": seed is None, "@changed": changed} - ) - return row - - -async def _stored(kind: str) -> list[dict[str, Any]]: - return [_row(r) for r in await repo.load(kind)] - - -async def rows(kind: str) -> list[dict[str, Any]]: - """DB 정본 줄(공종 축 둘만 파일) — 기계는 DB 노임·유가로 시간당 단가를 읽을 때마다 셈(식은 코드 · 브레인).""" - if kind in work_items.KINDS: - return work_items.base_rows(kind) - if kind != "machine": - return await _stored(kind) - out, labor, oil = await asyncio.gather(_stored("machine"), _stored("labor"), _stored("oil")) - inputs = _machine_inputs(labor, oil) - return [machine.compute(r, inputs) for r in out] +def rows(kind: str) -> list[dict[str, Any]]: + """덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈.""" + out = overrides.overlay(kind, base_rows(kind)) + if kind == "machine": + inputs = _machine_inputs() + out = [machine.compute(r, inputs) for r in out] + return out def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: @@ -484,26 +446,45 @@ def group_label(group: str) -> str: return named.get("name_ko") or group -async def kinds() -> list[dict[str, Any]]: +def kinds() -> list[dict[str, Any]]: """화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸). - 갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수(DB · 공종 축은 파일). + 갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수. """ - counted = await repo.counts() return [ { "group": kind_group(kind), "group_label": group_label(kind_group(kind)), "kind": kind, "label": kind_label(kind), - "rows": len(work_items.base_rows(kind)) - if kind in work_items.KINDS - else counted.get(kind, 0), + "rows": len(rows(kind)), } for kind in KINDS ] +def row_label(kind: str, row: dict[str, Any] | None) -> str: + if row is None: + return "" + if kind == "labor": + return str(row.get("occupation_name") or "") + if kind in work_items.KINDS: + return str(row.get("name") or "") + if kind == "machine": + return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip() + if kind == "material": + return str(row.get("specification") or row.get("classification_name") or "") + if kind == "oil": + return f"{row.get('source_product_name') or row.get('fuel')} {row.get('region_name') or '전국평균'}" + if kind in base_tables.SPEC: # 기초데이터 아홉 — 축 칸을 이어 사람이 읽을 한 줄로 + return " · ".join(str(row[c]) for c in row.get("@axis", ()) if row.get(c) is not None) + named = (tables.load_labels().get("value_overrides") or {}).get( + f"rates::variables/{row['variable']}" + ) or {} + bracket = row["@id"].split("/", 2)[2].replace(";", " · ") if row["part"] else "" + return f"{named.get('name_ko') or row['variable']} {bracket}".strip() + + def column_meta(kind: str, key: str) -> dict[str, Any]: """열 이름 — 이름표 `merged_tables[kind]` → `columns[열key]` → 영문 key(이름은 이름표에만 · 브레인 ②).""" labels = tables.load_labels() @@ -520,12 +501,27 @@ def column_meta(kind: str, key: str) -> dict[str, Any]: } -_INNER = ("@source", "@axis", "@data", "@added", "@changed") +#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨 +_NOT_MEANING = ("effective_date", "date", "notice_datetime") + + +def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, Any]: + """그 줄의 **값이 아닌 글자 칸**(이름·규격·단위·인도조건 …) — 이름표가 숨김 아니라 한 열만. + + 갱신으로 같은 코드에 다른 기준이 붙으면(노임 국가유산직종 개편 · 자재 KS 개정) 값만으로는 못 잡음. + """ + out = {} + for key, value in row.items(): + if key.startswith("@") or key in editable or key in _NOT_MEANING or key.endswith("_date"): + continue + if isinstance(value, str) and value.strip() and not column_meta(kind, key)["hidden"]: + out[key] = value + return out def public(row: dict[str, Any]) -> dict[str, Any]: - """내보낼 줄 — 안쪽 칸(`@source`·`@axis`·`@data`…)은 뺌.""" - return {k: v for k, v in row.items() if k not in _INNER} + """내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌.""" + return {k: v for k, v in row.items() if k not in ("@source", "@axis")} #: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」). @@ -542,7 +538,6 @@ def fold(text: str) -> str: def table( kind: str, - all_rows: list[dict[str, Any]], page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "", @@ -550,7 +545,7 @@ def table( desc: bool = False, filters: dict[str, str] | None = None, ) -> dict[str, Any]: - """한 표 한 쪽 — `all_rows` 는 `rows(kind)` 가 준 전체(DB 읽기와 셈을 떼어 둠).""" + all_rows = rows(kind) columns = _columns(kind, all_rows) needle = fold(q) hits = ( @@ -574,14 +569,7 @@ def table( "columns": [column_meta(kind, c) for c in columns], # 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림 "sortable": list(columns), - "rows": [ - { - **public(r), - "changed_columns": r.get("@changed", []), - "is_added": r.get("@added", False), - } - for r in hits[start : start + size] - ], + "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), "notice": notice(kind), @@ -590,84 +578,49 @@ def table( } -class SaveError(ValueError): - """[저장] 을 막는 까닭 — 하나라도 있으면 아무것도 안 씀(전부 아니면 전무).""" - - def __init__(self, status: int, message: str) -> None: - super().__init__(message) - self.status = status - - -def check_value(value: Any) -> str | None: - """값 칸에 넣을 수 있나 — 막는 까닭(없으면 None). null 은 빈 값(미공표 노임처럼).""" - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, (int, float)): - return "숫자만 넣을 수 있음" - if not math.isfinite(value) or value < 0: - return "0 이상 유한한 수만 넣을 수 있음" - return None - - -def _axis_of(kind: str) -> dict[str, tuple[str, ...]]: - """줄마다 축 칸 — 파일 원본에서(표 모양이라 값 고치기로 안 바뀜 · 주입 data 에 `@axis` 가 없어도 섬).""" - if kind not in base_tables.SPEC: - return {} - return {r["@id"]: tuple(r.get("@axis") or ()) for r in base_rows(kind)} - - -def _cell_problem( - row: dict[str, Any], column: str, value: Any, info: dict[str, Any], axis: dict, fillable: set -) -> str | None: - """한 칸 고치기를 막는 까닭 — 추가 줄은 저장 칸이면 글자 칸도(이름·규격을 사람이 넣은 줄).""" - if row["@added"]: - if column not in fillable: - return f"못 고치는 칸 {column} — {info['locked'].get(column) or '이 표에 없는 칸'}" - elif column in axis.get(row["@id"], ()): - return f"못 고치는 칸 {column} — {_KEY}" - elif column not in info["editable"] or column not in row: - return f"못 고치는 칸 {column} — {info['locked'].get(column) or '이 줄에 없는 칸'}" - if column in info["editable"]: - problem = check_value(value) - return f"{column}: {problem}" if problem else None - if isinstance(value, (dict, list)): - return f"{column}: 글자나 숫자만 넣을 수 있음" - return None - - -def plan_save( - kind: str, - shown: list[dict[str, Any]], - edits: list[dict[str, Any]], - added: list[dict[str, Any]], - deleted: list[str], -) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[str]]: - """[저장] 한 번 — 다 맞춰 본 뒤 쓸 것만: (고친 줄 data · 새 줄 data · 지울 줄). 막히면 SaveError. - - `shown` = `rows(kind)`(기계는 계산 칸까지 붙은 것) — 쓰는 값은 그 줄 `@data` 에 고친 칸만 얹음. - 새 줄 열쇠 = `added/{난수}`(브레인 계약에 열쇠 규칙이 없어 서버가 줌 · 응답으로 알림). - """ - by_key = {r["@id"]: r for r in shown} - info = spec(kind, _columns(kind, shown)) - axis = _axis_of(kind) - fillable = {c for r in shown for c in r["@data"] if not c.startswith("@")} # 계산 칸 제외 - changed: dict[str, dict[str, Any]] = {} - for edit in edits: - row = by_key.get(edit["row_key"]) - if row is None: - raise SaveError(404, f"없는 줄: {edit['row_key']}") - problem = _cell_problem(row, edit["column"], edit["value"], info, axis, fillable) +def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, dict[str, Any]]: + """고치기 — (상태 코드, 고친 뒤 그 줄 또는 막은 까닭).""" + base = next((r for r in base_rows(kind) if r["@id"] == row_id), None) + if base is None: + return 404, {"message": f"없는 줄: {row_id}"} + info = spec(kind, _columns(kind, rows(kind))) + for column, value in values.items(): + if column in (base.get("@axis") or ()): + return 400, {"message": f"못 고치는 칸 {column} — {_KEY}"} + if column not in info["editable"] or column not in base: + reason = info["locked"].get(column) or "이 줄에 없는 칸" + return 400, {"message": f"못 고치는 칸 {column} — {reason}"} + problem = overrides.check_value(value) if problem: - raise SaveError(400, problem) - changed.setdefault(row["@id"], dict(row["@data"]))[edit["column"]] = edit["value"] - new: dict[str, dict[str, Any]] = {} - for data in added: - for column, value in data.items(): - problem = _cell_problem({"@added": True}, column, value, info, axis, fillable) - if problem: - raise SaveError(400, problem) - new[f"added/{uuid.uuid4().hex[:12]}"] = dict(data) - missing = [key for key in deleted if key not in by_key] - if missing: - raise SaveError(404, f"없는 줄: {', '.join(missing)}") - return changed, new, list(dict.fromkeys(deleted)) + return 400, {"message": f"{column}: {problem}"} + overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"])) + return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) + + +def override_items( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict[str, Any]: + """모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수.""" + out = [] + counts: dict[str, dict[str, int]] = {} + for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만 + base = base_rows(each) + by_id = {r["@id"]: r for r in base} + for item in overrides.items(each, base): + counts.setdefault(each, {}).setdefault(item["state"], 0) + counts[each][item["state"]] += 1 + if (kind and each != kind) or (state and item["state"] != state): + continue + item["row_label"] = row_label(each, by_id.get(item["row_id"])) + meta = column_meta(each, item["column"]) + item["column_label"] = meta["label"] + item["unit"] = meta["unit"] + out.append(item) + out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"])) + size = max(1, min(size, tables.MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + return {"items": out[start : start + size], "total": len(out), "counts": counts} + + +def clear_overrides(kind: str, state: str) -> int: + return overrides.clear(kind, state, base_rows(kind)) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py index e0308862..350a84c8 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,14 +9,13 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import Decimal from functools import lru_cache from typing import Any from B09_Estimation.B09_Estimation_MachineCost import ( OPERATOR_WAGE_FORMULA, - MachineSpec, hourly_machine_cost, load_machine_catalog, ) @@ -41,7 +40,7 @@ _FORMULA = { _LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜" _LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님" _LOCKED = { - "machine_code": "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음", + "machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음", "machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜", "specification": "공표 원문 칸 — 원본 갱신으로만 바뀜", "economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", @@ -51,8 +50,8 @@ _LOCKED = { "misc_material_percent": _LOCKED_OPERATING, "operator_occupation_code": _LOCKED_OPERATING, "operator_person_per_day": _LOCKED_OPERATING, - "fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 전국평균을 끌어 씀", - "operator_daily_wage_krw": "노임 표에서 고침 — 여기는 건설 노임을 끌어 씀", + "fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀", + "operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀", "hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬", "effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜", "operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일", @@ -76,7 +75,7 @@ def _fmt(value: Decimal | int | float) -> str: @dataclass(frozen=True) class Inputs: - """기계 셈이 다른 표에서 끌어 오는 값 — DB 지금 값 · 판 기준일과 함께.""" + """기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께.""" wages: dict[str, Decimal | None] wage_date: str | None @@ -124,13 +123,10 @@ def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, An def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: - """DB 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길. - - 기종 제원은 줄 값에서(카탈로그 아님) — 관리자가 추가한 줄도 섬 · 운전경비 원단위는 코드로 찾아 없으면 빈칸. - """ - _, records = _inputs() + """덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.""" + catalog, records = _inputs() code = row["@id"] - coef = row.get("loss_coefficient_per_hour") + coef = row["loss_coefficient_per_hour"] record = records.get(code) out = { **row, @@ -141,17 +137,13 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: "operator_wage_date": None, **dict.fromkeys(COMPUTED), } - if coef is None or row.get("price_thousand_krw") is None: - out["hourly_note"] = f"{'손료계수' if coef is None else '취득가'} 없음 — 시간당 단가 안 섬" + if coef is None: + out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬" return out - spec = MachineSpec( - machine_code=code, - name=str(row.get("machine_name") or ""), - specification=str(row.get("specification") or ""), + spec = replace( + catalog.machines[code], price_thousand_krw=Decimal(str(row["price_thousand_krw"])), loss_coefficient_per_hour=Decimal(str(coef)), - economic_life_hours=row.get("economic_life_hours"), - annual_standard_hours=row.get("annual_standard_hours"), ) liters = fuel_price = wage = None if record is not None: diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index a0f55798..a89bdf7f 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 new file mode 100644 index 00000000..9cdc5a8e --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Overrides.py @@ -0,0 +1,202 @@ +"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」). + + 원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움 + 덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일 + 읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}` + +state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음): + edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본) + source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함) + orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김 +되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌. +""" + +from __future__ import annotations + +import json +import math +import threading +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override" +EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan" +#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격). +#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16). +MEANING_CHANGED = "meaning_changed" +STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위 +_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요 + + +def _path(kind: str) -> Path: + return OVERRIDE_DIR / f"{kind}.json" + + +def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]: + """`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개.""" + path = _path(kind) + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {} + + +def same(a: Any, b: Any) -> bool: + """값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음.""" + if a is None or b is None: + return a is None and b is None + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return Decimal(str(a)) == Decimal(str(b)) + return a == b + + +def check_value(value: Any) -> str | None: + """넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과.""" + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "숫자만 넣을 수 있음" + if not math.isfinite(value) or value < 0: + return "0 이상 유한한 수만 넣을 수 있음" + return None + + +#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌. +_JOIN = " · " + + +def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]: + """(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘.""" + stored = entry.get("meaning") or {} + keys = [k for k in stored if row is not None and k in row] + if not keys or all(row[k] == stored[k] for k in keys): + return "", "" + return ( + _JOIN.join(str(stored[k]) for k in keys), + _JOIN.join(str(row[k]) for k in keys), + ) + + +def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]: + original, value = entry.get("original"), entry.get("value") + was, now = meaning_line(entry, row) + if was: + state = MEANING_CHANGED + else: + state = EDITED if same(original, current) else SOURCE_CHANGED + out = {"original": original, "value": value, "state": state, "current": current} + if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감 + out["was"], out["now"] = was, now + return out + + +def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**.""" + store = load(kind) + out = [] + for row in rows: + entries = store.get(row["@id"]) + if not entries: + out.append(row) + continue + row = dict(row) + marks = {} + for column, entry in entries.items(): + if column not in row: + continue # 주인 없는 칸 — 모아 보기에만 + marks[column] = mark(entry, row[column], row) + row[column] = entry.get("value") + if marks: + row["@overrides"] = marks + out.append(row) + return out + + +def write( + kind: str, + base_row: dict[str, Any], + values: dict[str, Any], + by: Any, + meaning: dict[str, Any] | None = None, +) -> None: + """고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림.""" + row_id = base_row["@id"] + with _LOCK: + store = load(kind) + entries = dict(store.get(row_id) or {}) + for column, value in values.items(): + current = base_row.get(column) + if value is None or same(value, current): + entries.pop(column, None) + continue + entries[column] = { + "value": value, + "original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed + "source": base_row.get("@source", ""), + # 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음 + "meaning": meaning or {}, + "at": datetime.now().astimezone().isoformat(timespec="seconds"), + "by": by, + } + if entries: + store[row_id] = entries + else: + store.pop(row_id, None) + _save(kind, store) + + +def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None: + atomic_write_json( + _path(kind), + { + "schema_version": "1.0", + "kind": kind, + "note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀", + "overrides": store, + }, + ) + + +def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수.""" + with _LOCK: + doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state} + if not doomed: + return 0 + store = load(kind) + for row_id, column in doomed: + store[row_id].pop(column, None) + if not store[row_id]: + del store[row_id] + _save(kind, store) + return len(doomed) + + +def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로.""" + by_id = {r["@id"]: r for r in base_rows} + out = [] + for row_id, entries in load(kind).items(): + row = by_id.get(row_id) + for column, entry in entries.items(): + if row is None or column not in row: + current, marked = None, {"state": ORPHAN} + else: + current = row[column] + marked = mark(entry, current, row) + out.append( + { + "kind": kind, + "row_id": row_id, + "column": column, + "original": entry.get("original"), + "current": current, + "value": entry.get("value"), + "state": marked["state"], + **({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}), + } + ) + return out diff --git a/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py b/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py deleted file mode 100644 index a3013799..00000000 --- a/Z01_MasterData/Z01_MasterData_Repository_BasePrices.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Z01 기초단가 저장소 — `master_base_price` · `master_revision` (020 · PLAN 1-0 계약 · 2026-09-18). - -정본 = DB. 줄 = (kind, row_key) 한 줄 · `data` 는 지금 값 한 줄 전체 · `seed` 는 주입 때 초기값(관리자 추가 줄은 NULL). -판번호 = `master_revision.revision`(scope `base:{kind}`) — 쓸 때마다 +1 · 읽어 간 판과 다르면 안 씀(409). -⚠ 여기는 SQL 만 — 무엇을 고칠 수 있나·값 검사는 `Z01_MasterData_BasePrices` 가 함. -""" - -from __future__ import annotations - -import json -from typing import Any - -import aiomysql - -from config.config_db import get_db_pool - - -class RevisionConflict(Exception): - """읽어 간 판번호가 지금 판과 다름 — 그 사이 누가 저장함.""" - - -class UnknownRows(LookupError): - """없는 줄 열쇠 — 되돌릴 줄을 못 찾음.""" - - -def _scope(kind: str) -> str: - return f"base:{kind}" - - -def _json(text: Any) -> Any: - return json.loads(text) if isinstance(text, (str, bytes)) else text - - -def _dump(data: dict[str, Any]) -> str: - return json.dumps(data, ensure_ascii=False, default=str) - - -async def load(kind: str) -> list[dict[str, Any]]: - """지운 줄 뺀 전부 — 넣은 차례(id) 그대로. {row_key, data, seed}.""" - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT row_key, data, seed FROM master_base_price - WHERE kind = %s AND is_deleted = 0 ORDER BY id""", - (kind,), - ) - return [ - {"row_key": r["row_key"], "data": _json(r["data"]), "seed": _json(r["seed"])} - for r in await cursor.fetchall() - ] - - -async def revision(kind: str) -> int: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - "SELECT revision FROM master_revision WHERE scope = %s", (_scope(kind),) - ) - row = await cursor.fetchone() - return int(row[0]) if row else 0 - - -async def counts() -> dict[str, int]: - """kind 마다 줄 수(지운 줄 뺌) — 표 목록 한 번에.""" - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - "SELECT kind, COUNT(*) FROM master_base_price WHERE is_deleted = 0 GROUP BY kind" - ) - return {kind: int(n) for kind, n in await cursor.fetchall()} - - -async def _lock_revision(cursor: Any, kind: str) -> int: - """판번호 줄을 잠그고 읽음 — 없으면 0 으로 세움(같은 kind 쓰기는 여기서 한 줄로 섬).""" - await cursor.execute( - "INSERT IGNORE INTO master_revision (scope, revision) VALUES (%s, 0)", (_scope(kind),) - ) - await cursor.execute( - "SELECT revision FROM master_revision WHERE scope = %s FOR UPDATE", (_scope(kind),) - ) - return int((await cursor.fetchone())[0]) - - -async def _bump(cursor: Any, kind: str) -> None: - await cursor.execute( - "UPDATE master_revision SET revision = revision + 1 WHERE scope = %s", (_scope(kind),) - ) - - -async def save( - kind: str, - base_revision: int, - changed: dict[str, dict[str, Any]], - added: dict[str, dict[str, Any]], - deleted: list[str], - by: int | None, -) -> int: - """한 트랜잭션 — 판이 맞을 때만 고친 줄·새 줄·지운 줄을 한꺼번에(전부 아니면 전무). 새 판번호.""" - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - current = await _lock_revision(cursor, kind) - if current != base_revision: - raise RevisionConflict(current) - for row_key, data in changed.items(): - await cursor.execute( - """UPDATE master_base_price - SET data = %s, updated_by = %s, updated_at = CURRENT_TIMESTAMP - WHERE kind = %s AND row_key = %s AND is_deleted = 0""", - (_dump(data), by, kind, row_key), - ) - for row_key, data in added.items(): - await cursor.execute( - """INSERT INTO master_base_price - (kind, row_key, data, seed, edition, is_deleted, updated_by, updated_at) - VALUES (%s, %s, %s, NULL, %s, 0, %s, CURRENT_TIMESTAMP)""", - (kind, row_key, _dump(data), str(data.get("effective_date") or ""), by), - ) - for row_key in deleted: - await cursor.execute( - """UPDATE master_base_price - SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP - WHERE kind = %s AND row_key = %s""", - (by, kind, row_key), - ) - await _bump(cursor, kind) - await connection.commit() - return current + 1 - except Exception: - await connection.rollback() - raise - - -async def reset(kind: str, row_keys: list[str], by: int | None) -> int: - """고른 줄을 초기값으로 — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄(seed NULL)은 지움. 새 판번호.""" - keys = list(dict.fromkeys(row_keys)) - marks = ", ".join(["%s"] * len(keys)) - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - current = await _lock_revision(cursor, kind) - await cursor.execute( - f"SELECT row_key FROM master_base_price WHERE kind = %s AND row_key IN ({marks})", - (kind, *keys), - ) - missing = set(keys) - {r[0] for r in await cursor.fetchall()} - if missing: - raise UnknownRows(sorted(missing)) - await cursor.execute( - f"""UPDATE master_base_price - SET data = seed, is_deleted = 0, updated_by = %s, updated_at = CURRENT_TIMESTAMP - WHERE kind = %s AND seed IS NOT NULL AND row_key IN ({marks})""", - (by, kind, *keys), - ) - await cursor.execute( - f"""UPDATE master_base_price - SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP - WHERE kind = %s AND seed IS NULL AND row_key IN ({marks})""", - (by, kind, *keys), - ) - await _bump(cursor, kind) - await connection.commit() - return current + 1 - except Exception: - await connection.rollback() - raise diff --git a/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py b/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py deleted file mode 100644 index 11f0658a..00000000 --- a/Z01_MasterData/Z01_MasterData_Repository_WorkItems.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Z01 공종 저장소 — `master_work_item`·`master_work_item_row`·`master_revision` 읽기·쓰기(SQL 만). - -무엇을 쓸지는 `Z01_MasterData_WorkItems` 순수 함수(`plan_rows`·`reset_plan`)가 정함 · -여기는 받은 대로 씀. -표 모양 = `db_management/020_master_cost.sql`(PLAN 1-0 계약). 트랜잭션은 부르는 쪽(라우터)이 엶. -""" - -from __future__ import annotations - -import json -from typing import Any - -import aiomysql - -HEAD_COLUMNS = ( - "id, axis, work_item_key, code, number, name, path_name, parent_key, parent_mode, " - "basis_quantity, basis_unit, status, notes, source, seed, edition, sort_order, " - "updated_by, updated_at" -) -ROW_COLUMNS = ( - "id, variant, resource_kind, resource_key, resource_name, resource_spec, amount, " - "amount_unit, alternative_amount, group_ratio_pct, raw_cell, pum_table_id, seed, " - "sort_order, is_deleted" -) -_JSON = ("notes", "source", "seed") - - -def _parsed(row: dict[str, Any]) -> dict[str, Any]: - """JSON 칸은 MariaDB 가 글자로 돌려줌 — 풀어서 씀.""" - for key in _JSON: - if isinstance(row.get(key), (str, bytes)): - row[key] = json.loads(row[key]) - return row - - -def _like(q: str) -> str: - return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" - - -async def list_items( - connection: aiomysql.Connection, *, axis: str, status: str, q: str, page: int, size: int -) -> dict[str, Any]: - """목록 한 쪽 + 상태별 개수(상태 거르기 전 · 탭 숫자용). total 은 개수에서 셈(질의 하나 덜).""" - where, params = ["1"], [] - if axis: - where.append("w.axis = %s") - params.append(axis) - if q: - where.append("(w.work_item_key LIKE %s OR w.number LIKE %s OR w.path_name LIKE %s)") - params += [_like(q)] * 3 - picked, picked_params = list(where), list(params) - if status: - picked.append("w.status = %s") - picked_params.append(status) - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - "SELECT w.status, COUNT(*) AS n FROM master_work_item w " - f"WHERE {' AND '.join(where)} GROUP BY w.status", - params, - ) - counts = {row["status"]: int(row["n"]) for row in await cursor.fetchall()} - await cursor.execute( - "SELECT w.work_item_key, w.axis, w.number, w.name, w.path_name, w.status, " - "(SELECT COUNT(*) FROM master_work_item_row r " - " WHERE r.work_item_id = w.id AND r.is_deleted = 0) AS row_count " - f"FROM master_work_item w WHERE {' AND '.join(picked)} " - "ORDER BY w.axis, w.sort_order, w.id LIMIT %s OFFSET %s", - [*picked_params, size, (page - 1) * size], - ) - items = list(await cursor.fetchall()) - total = counts.get(status, 0) if status else sum(counts.values()) - return {"items": items, "total": total, "counts": counts} - - -async def fetch_item( - connection: aiomysql.Connection, key: str, *, lock: bool = False -) -> dict[str, Any] | None: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - f"SELECT {HEAD_COLUMNS} FROM master_work_item WHERE work_item_key = %s" - + (" FOR UPDATE" if lock else ""), - (key,), - ) - row = await cursor.fetchone() - return _parsed(row) if row else None - - -async def fetch_rows( - connection: aiomysql.Connection, item_id: int, *, with_deleted: bool = False -) -> list[dict[str, Any]]: - """구성 줄 — 기본은 살아 있는 줄만 · [초기값으로] 는 숨긴 줄까지.""" - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - f"SELECT {ROW_COLUMNS} FROM master_work_item_row WHERE work_item_id = %s" - + ("" if with_deleted else " AND is_deleted = 0") - + " ORDER BY sort_order, id", - (item_id,), - ) - return [_parsed(row) for row in await cursor.fetchall()] - - -async def revision(connection: aiomysql.Connection, scope: str, *, lock: bool = False) -> int: - """지금 revision(없으면 0). lock 이면 줄을 만들어 잡음 — 같은 공종 저장이 줄 서게.""" - async with connection.cursor() as cursor: - if lock: - await cursor.execute( - "INSERT IGNORE INTO master_revision (scope, revision) VALUES (%s, 0)", (scope,) - ) - await cursor.execute( - "SELECT revision FROM master_revision WHERE scope = %s" - + (" FOR UPDATE" if lock else ""), - (scope,), - ) - row = await cursor.fetchone() - return int(row[0]) if row else 0 - - -async def bump_revision(connection: aiomysql.Connection, scope: str) -> None: - async with connection.cursor() as cursor: - await cursor.execute( - "INSERT INTO master_revision (scope, revision) VALUES (%s, 1) " - "ON DUPLICATE KEY UPDATE revision = revision + 1", - (scope,), - ) - - -async def known_resource_keys(connection: aiomysql.Connection, keys: set[str]) -> set[str]: - """그중 기초단가(`master_base_price.row_key`)에 실재하는 열쇠.""" - if not keys: - return set() - marks = ", ".join(["%s"] * len(keys)) - async with connection.cursor() as cursor: - await cursor.execute( - "SELECT DISTINCT row_key FROM master_base_price " - f"WHERE is_deleted = 0 AND row_key IN ({marks})", - list(keys), - ) - return {row[0] for row in await cursor.fetchall()} - - -async def write( - connection: aiomysql.Connection, - item_id: int, - head: dict[str, Any], - plan: dict[str, list[Any]], - by: Any, -) -> None: - """머리 칸 + 줄 쓰기(`plan_rows`·`reset_plan` 모양). - - 칸 이름은 순수 함수의 고정 목록에서만 옴(요청 글자가 SQL 에 안 들어감). - """ - async with connection.cursor() as cursor: - sets = "".join(f"{field} = %s, " for field in head) - await cursor.execute( - f"UPDATE master_work_item SET {sets}updated_by = %s, updated_at = NOW() WHERE id = %s", - [*head.values(), by, item_id], - ) - for row in plan["updates"]: # 줄마다 칸이 다를 수 있음(seed 에 있는 칸만) — 한 공종 수십 줄 - values = {k: v for k, v in row.items() if k != "id"} - sets = "".join(f"{field} = %s, " for field in values) - await cursor.execute( - f"UPDATE master_work_item_row SET {sets}is_deleted = 0 " - "WHERE id = %s AND work_item_id = %s", - [*values.values(), row["id"], item_id], - ) - if plan["inserts"]: - fields = list(plan["inserts"][0]) - await cursor.executemany( - f"INSERT INTO master_work_item_row (work_item_id, {', '.join(fields)}) " - f"VALUES (%s, {', '.join(['%s'] * len(fields))})", - [[item_id, *(row[f] for f in fields)] for row in plan["inserts"]], - ) - for ids, sql in ( - (plan["hide"], "UPDATE master_work_item_row SET is_deleted = 1"), - (plan["drop"], "DELETE FROM master_work_item_row"), - ): - if ids: - await cursor.execute( - f"{sql} WHERE work_item_id = %s AND id IN ({', '.join(['%s'] * len(ids))})", - [item_id, *ids], - ) diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index d47be921..294d1337 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -1,69 +1,32 @@ -"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약). +"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인). - GET /api/master-data/tree 갈래 → 파일 → 표 - GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) - GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함) - GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표 + revision · 줄마다 changed_columns · is_added - PUT /api/master-data/base-prices/{kind} {base_revision, edits, added, deleted} → 409 · 전부 아니면 전무 - POST /api/master-data/base-prices/{kind}/reset {row_keys} → 초기값으로(추가 줄은 지움) + GET /api/master-data/tree 갈래 → 파일 → 표 + GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) + GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함) + GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘) + PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 + GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. -⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장). """ from __future__ import annotations -import asyncio -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict, Field -from starlette.concurrency import run_in_threadpool +from pydantic import BaseModel from common_util.common_util_auth import verify_session from Z01_MasterData import Z01_MasterData_BasePrices as base_prices -from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo from Z01_MasterData import Z01_MasterData_Tables as tables from Z01_MasterData import Z01_MasterData_WorkItems as work_items router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) -_STALE = "그 사이 누가 이 표를 저장했습니다 — 표를 다시 불러와 고칠 것." - -class CellEdit(BaseModel): - model_config = ConfigDict(extra="forbid") - - row_key: str = Field(min_length=1, max_length=190) - column: str = Field(min_length=1, max_length=100) - value: Any = None - - -class AddedRow(BaseModel): - model_config = ConfigDict(extra="forbid") - - data: dict[str, Any] = Field(min_length=1) - - -class BasePriceSave(BaseModel): - """[저장] 한 번 — 읽어 간 판번호와 함께 고친 칸·새 줄·지운 줄.""" - - model_config = ConfigDict(extra="forbid") - - base_revision: int = Field(ge=0) - edits: list[CellEdit] = Field(default_factory=list, max_length=500) - added: list[AddedRow] = Field(default_factory=list, max_length=500) - deleted: list[str] = Field(default_factory=list, max_length=500) - - -class BasePriceReset(BaseModel): - model_config = ConfigDict(extra="forbid") - - row_keys: list[str] = Field(min_length=1, max_length=10000) - - -def _error(status: int, message: str) -> JSONResponse: - return JSONResponse(status_code=status, content={"status": "error", "message": message}) +class BasePriceEdit(BaseModel): + values: dict[str, Any] @router.get("/tree") @@ -87,19 +50,14 @@ def _kind(kind: str) -> str: return kind -def _db_kind(kind: str) -> str | None: - """고칠 수 있는 표(DB 정본) — 공종 축은 여기서 안 고침(`/work-items` 몫).""" - return kind if kind in base_prices.KINDS and kind not in work_items.KINDS else None - - @router.get("/base-prices") -async def get_base_price_kinds() -> dict: +def get_base_price_kinds() -> dict: """어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄.""" - return {"status": "success", "kinds": await base_prices.kinds()} + return {"kinds": base_prices.kinds()} @router.get("/base-prices/{kind}") -async def get_base_prices( +def get_base_prices( kind: str, request: Request, page: int = 1, @@ -108,71 +66,39 @@ async def get_base_prices( sort: str = "", desc: int = 0, ) -> dict: - kind = _kind(kind) # 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실) picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS} - if _db_kind(kind): - all_rows, revision = await asyncio.gather(base_prices.rows(kind), repo.revision(kind)) - else: - all_rows, revision = await base_prices.rows(kind), 0 try: - table = await run_in_threadpool( - base_prices.table, - kind, - all_rows, - page=page, - size=size, - q=q, - sort=sort, - desc=bool(desc), - filters=picked, + return base_prices.table( + _kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked ) except base_prices.SortError as error: raise HTTPException(status_code=400, detail=str(error)) from error - return {"status": "success", "revision": revision, **table} -@router.put("/base-prices/{kind}") -async def put_base_prices( - kind: str, body: BasePriceSave, session: dict = Depends(verify_session) +@router.put("/base-prices/{kind}/{row_id:path}") +def put_base_price( + kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session) ) -> JSONResponse: - """고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀).""" - if not _db_kind(kind): - return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}") - if await repo.revision(kind) != body.base_revision: - return _error(409, _STALE) - try: - changed, added, deleted = base_prices.plan_save( - kind, - await base_prices.rows(kind), - [edit.model_dump() for edit in body.edits], - [row.data for row in body.added], - body.deleted, - ) - except base_prices.SaveError as error: - return _error(error.status, str(error)) - if not (changed or added or deleted): - return JSONResponse( - content={"status": "success", "revision": body.base_revision, "added": []} - ) - try: - revision = await repo.save( - kind, body.base_revision, changed, added, deleted, session.get("user_id") - ) - except repo.RevisionConflict: - return _error(409, _STALE) - return JSONResponse(content={"status": "success", "revision": revision, "added": list(added)}) + status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id")) + if status != 200: + raise HTTPException(status_code=status, detail=payload["message"]) + return JSONResponse(content=payload) -@router.post("/base-prices/{kind}/reset") -async def reset_base_prices( - kind: str, body: BasePriceReset, session: dict = Depends(verify_session) -) -> JSONResponse: - """고른 줄을 초기값으로 — 주입 줄은 data := seed · 추가 줄은 지움. 판번호 +1.""" - if not _db_kind(kind): - return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}") - try: - revision = await repo.reset(kind, body.row_keys, session.get("user_id")) - except repo.UnknownRows as error: - return _error(404, f"없는 줄: {', '.join(error.args[0])}") - return JSONResponse(content={"status": "success", "revision": revision}) +@router.get("/overrides") +def get_overrides( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict: + return base_prices.override_items(kind and _kind(kind), state, page=page, size=size) + + +class OverrideClear(BaseModel): + kind: str + state: Literal["edited", "source_changed", "orphan"] + + +@router.post("/overrides/clear") +def clear_overrides(body: OverrideClear, _session: dict = Depends(verify_session)) -> dict: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — kind·state 둘 다 있어야 함(통째로 비우기 막음).""" + return {"removed": base_prices.clear_overrides(_kind(body.kind), body.state)} diff --git a/Z01_MasterData/Z01_MasterData_Router_WorkItems.py b/Z01_MasterData/Z01_MasterData_Router_WorkItems.py deleted file mode 100644 index dfbac857..00000000 --- a/Z01_MasterData/Z01_MasterData_Router_WorkItems.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Z01 공종 편집 라우터 — 공종 머리·구성 줄을 관리자가 고치고 메움(2026-09-18 PLAN 1-0 계약). - - GET /api/master-data/work-items?axis&status&q&page&size 목록 + 상태별 개수 - GET /api/master-data/work-items/{key} 머리(+revision)·구성 줄·원문 표·[주] - PUT /api/master-data/work-items/{key} 전체 줄 보내기 - → 409(낡은 revision) · 422(검사) - POST /api/master-data/work-items/{key}/reset 머리·줄을 초기값으로(추가 줄 삭제) -쓰기는 한 트랜잭션(전부 아니면 전무) · 응답은 새 GET 모양 그대로(화면이 다시 안 불러도 됨). -⚠ 권한은 등록하는 쪽(`main.py`)이 시스템 관리자 전용 묶음으로 붙임. -""" - -from __future__ import annotations - -from decimal import Decimal -from typing import Any, Literal - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict, Field - -from common_util.common_util_auth import verify_session -from config.config_db import get_db_pool -from Z01_MasterData import Z01_MasterData_Repository_WorkItems as repo -from Z01_MasterData import Z01_MasterData_Tables as tables -from Z01_MasterData import Z01_MasterData_WorkItems as work_items - -router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData WorkItems"]) - -Status = Literal[work_items.STATUSES] # 튜플을 펼침 — 상태 이름은 한 곳(`STATUSES`) -MAX_ROWS = 500 - - -class _Strict(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class ItemHead(_Strict): - name: str = Field(min_length=1) - basis_quantity: Decimal | None = Field(default=None, ge=0) - basis_unit: str | None = None - status: Status - - -class ItemRow(_Strict): - id: int | None = None # 있으면 수정 · 없으면 추가 - variant: str = "" - resource_kind: Literal["labor", "machine", "material", "etc"] - resource_key: str | None = Field(default=None, min_length=1) # 못 맞춘 줄은 null(빈 글자 금지) - resource_name: str = "" - resource_spec: str = "" - amount: Decimal | None = Field(default=None, ge=0) - amount_unit: str = "" - alternative_amount: Decimal | None = Field(default=None, ge=0) - group_ratio_pct: Decimal | None = Field(default=None, ge=0) - raw_cell: str | None = None - pum_table_id: str = "" - - -class WorkItemSave(_Strict): - base_revision: int - item: ItemHead - rows: list[ItemRow] = Field(max_length=MAX_ROWS) # 전체 줄 — 빠진 줄은 삭제 - - -@router.get("/work-items") -async def list_work_items( - axis: Literal["", "forest", "const"] = "", - status: Status | Literal[""] = "", - q: str = "", - page: int = 1, - size: int = tables.DEFAULT_PAGE_SIZE, -) -> dict[str, Any]: - page, size = max(page, 1), max(1, min(size, tables.MAX_PAGE_SIZE)) - async with get_db_pool().acquire() as connection: - found = await repo.list_items( - connection, axis=axis, status=status, q=q.strip(), page=page, size=size - ) - counts = {s: found["counts"].get(s, 0) for s in work_items.STATUSES} - return {"status": "success", **found, "counts": counts, "page": page, "size": size} - - -async def _detail(connection: Any, key: str) -> dict[str, Any]: - head = await repo.fetch_item(connection, key) - if head is None: - raise HTTPException(status_code=404, detail=f"없는 공종: {key}") - rows = await repo.fetch_rows(connection, head["id"]) - revision = await repo.revision(connection, work_items.revision_scope(key)) - return {"status": "success", **work_items.detail(head, rows, revision)} - - -@router.get("/work-items/{key}") -async def get_work_item(key: str) -> dict[str, Any]: - async with get_db_pool().acquire() as connection: - return await _detail(connection, key) - - -async def _locked(connection: Any, key: str) -> tuple[dict[str, Any], int]: - """공종 머리 + revision 을 잡음(같은 공종 저장이 줄 섬).""" - head = await repo.fetch_item(connection, key, lock=True) - if head is None: - raise HTTPException(status_code=404, detail=f"없는 공종: {key}") - return head, await repo.revision(connection, work_items.revision_scope(key), lock=True) - - -@router.put("/work-items/{key}") -async def put_work_item( - key: str, body: WorkItemSave, session: dict = Depends(verify_session) -) -> dict[str, Any]: - rows = [row.model_dump() for row in body.rows] - async with get_db_pool().acquire() as connection: - await connection.begin() - try: - head, current = await _locked(connection, key) - if current != body.base_revision: - raise HTTPException( - status_code=409, - detail="다른 곳에서 먼저 고쳤음 — 다시 불러올 것" - f"(지금 {current} · 보낸 {body.base_revision})", - ) - existing = await repo.fetch_rows(connection, head["id"]) - known = await repo.known_resource_keys( - connection, {row["resource_key"] for row in rows if row["resource_key"]} - ) - errors = work_items.check_rows(rows, {row["id"] for row in existing}, known) - if errors: - raise HTTPException(status_code=422, detail="\n".join(errors)) - plan = work_items.plan_rows(existing, rows) - await repo.write( - connection, head["id"], body.item.model_dump(), plan, session.get("user_id") - ) - await repo.bump_revision(connection, work_items.revision_scope(key)) - await connection.commit() - except BaseException: - await connection.rollback() - raise - return await _detail(connection, key) - - -@router.post("/work-items/{key}/reset") -async def reset_work_item(key: str, session: dict = Depends(verify_session)) -> dict[str, Any]: - async with get_db_pool().acquire() as connection: - await connection.begin() - try: - head, _current = await _locked(connection, key) - rows = await repo.fetch_rows(connection, head["id"], with_deleted=True) - plan = work_items.reset_plan(head, rows) - await repo.write(connection, head["id"], plan["head"], plan, session.get("user_id")) - await repo.bump_revision(connection, work_items.revision_scope(key)) - await connection.commit() - except BaseException: - await connection.rollback() - raise - return await _detail(connection, key) diff --git a/Z01_MasterData/Z01_MasterData_UI_Draft.ts b/Z01_MasterData/Z01_MasterData_UI_Draft.ts deleted file mode 100644 index 60412835..00000000 --- a/Z01_MasterData/Z01_MasterData_UI_Draft.ts +++ /dev/null @@ -1,263 +0,0 @@ -/* ============================================================================= - * Z01_MasterData_UI_Draft.ts - * 고친 것 초안 — [저장] 전까지 이 탭(sessionStorage)에만 둠 · 판(revision)이 다르면 버림 - * - * 본보기 `B08_Quantity_UI_StructureSummary.ts` 의 Draft · 열쇠 `z01-draft:{scope}` - * (scope = `base:{kind}` · `work_item:{key}`) — PLAN 1-0 계약 · 자동저장 금지(CLAUDE.md 5장). - * 런타임 import 없음 — 시험(`test_z01_master_draft.py`)이 그대로 돌림. - * ========================================================================== */ - -import type { - BaseSave, - WorkItemDetail, - WorkItemHead, - WorkItemRow, - WorkItemSave, -} from "./Z01_MasterData_Api_Fetch"; - -const PREFIX = "z01-draft:"; - -/** 저장소의 초안 — 판이 다르거나 못 읽으면 null(낡은 판 초안은 지움). */ -export function readDraft(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 8cfa43bf..021e9198 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -15,9 +15,8 @@ import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; import type { ServedKind } from "./Z01_MasterData_UI_Cells"; -import { buildRowsView, type RowsView } from "./Z01_MasterData_UI_Rows"; +import { buildRowsView } from "./Z01_MasterData_UI_Rows"; import { buildSidePanel } from "./Z01_MasterData_UI_Side"; -import { buildWorkItemView } from "./Z01_MasterData_UI_WorkItem"; import "./Z01_MasterData_UI_Style.css"; export async function renderZ01MasterData(root: HTMLElement): Promise { @@ -42,32 +41,12 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { } function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement { - // 오른쪽은 표 보기 · 공종 편집 둘 중 하나 — 왼쪽에서 누른 쪽만 보임. - const rows = buildRowsView(); - const items = buildWorkItemView(); - items.root.hidden = true; - const swap = (showRows: boolean): void => { - rows.root.hidden = !showRows; - items.root.hidden = showRows; - }; - const view: RowsView = { - root: rows.root, - show: (source) => { - swap(true); - rows.show(source); - }, - }; + const view = buildRowsView(); const layout = el("div", { className: "ui-workflow-layout z01-master" }); - const main = el("main", { - className: "ui-workflow-layout__main", - children: [rows.root, items.root], - }); + const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - optionsContent: buildSidePanel(groups, kinds, view, (axis) => { - swap(false); - items.show(axis); - }), + optionsContent: buildSidePanel(groups, kinds, view), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 1735ed17..7309885c 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -1,11 +1,10 @@ /* ============================================================================= * Z01_MasterData_UI_Rows.ts - * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 고침 + * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 * - * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `edit` 이 있어 고침. - * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 산출근거가 섬 + * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. + * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). - * ⚠ 자동저장 금지 — 고친 칸·추가 줄·삭제 줄은 초안(`BaseDraft`)에 쌓고 [저장] 한 번에 보냄(PLAN 1-0). * ========================================================================== */ import { @@ -13,22 +12,14 @@ import { createInputField, createSelectField, el, - hideLoadingOverlay, showConfirmDialog, - showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { - ApiError, - type BaseSave, - type MasterRows, - type RowsQuery, -} from "./Z01_MasterData_Api_Fetch"; +import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { boldSegments, cellInfo, - cellText, clampPage, columnTitle, filterValue, @@ -41,29 +32,24 @@ import { rowCells, shownColumns, valueWithUnit, + type CellInfo, type MasterColumn, type SortOrder, } from "./Z01_MasterData_UI_Cells"; -import { BaseDraft, type AddedRow } from "./Z01_MasterData_UI_Draft"; const PAGE_SIZE = 50; const SEARCH_DELAY_MS = 300; type Row = Record; -type LocaleKey = Parameters[0]; export interface RowsSource { title: string; key: string; - /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */ + /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ searchable?: boolean; load: (query: RowsQuery) => Promise; - /** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */ - edit?: { - scope: string; - save: (body: BaseSave) => Promise; - reset: (rowKeys: string[]) => Promise; - }; + /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ + save?: (rowId: string, values: Row) => Promise; } export interface RowsView { @@ -72,15 +58,36 @@ export interface RowsView { } /** 사전 문구의 {value} 자리에 값을 넣음. */ -function fill(key: LocaleKey, value: string): string { +function fill(key: Parameters[0], value: string): string { return L(key).replace("{value}", value); } -/** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */ -interface Picked { - row: string; - added: boolean; - column: string; +/** + * 뜻이 바뀐 줄의 옛 이름·지금 이름 — 서버가 합쳐 한 줄로 줌(자재는 품명·규격·인도조건이 이어 붙어 옴). + * 화면은 쪼개거나 다시 합치지 않음. 안 오면 버그로 드러냄. + */ +function meaningNames(info: CellInfo): string { + if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing"); + return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`; +} + +/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ +function stateText(info: CellInfo, column: MasterColumn): string { + const original = valueWithUnit(info.original, column.unit); + switch (info.state) { + case "meaning_changed": + // 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히. + // ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김. + return `${L("Z01_MasterData_State_MeaningChanged")} — ${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`; + case "source_changed": + return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + case "orphan": + return L("Z01_MasterData_State_Orphan"); + case "missing": + return L("Z01_MasterData_State_Missing"); + default: + return fill("Z01_MasterData_State_Edited", original); + } } export function buildRowsView(): RowsView { @@ -93,10 +100,7 @@ export function buildRowsView(): RowsView { }; let showHidden = false; let last: MasterRows | null = null; - let picked: Picked | null = null; - let draft: BaseDraft | null = null; - /** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */ - let stale = false; + let selected: { index: number; key: string } | null = null; let searchTimer = 0; const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); @@ -149,26 +153,12 @@ export function buildRowsView(): RowsView { className: "z01-master__pager", children: [first, prev, pageInput, pageInfo, next, end], }); - // 기초단가 고치기 — [저장]·[고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] - const action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") => - createButton({ label: L(key), variant, onClick }); - const saveButton = action("Z01_MasterData_Save", () => void save(), "filled"); - const discardButton = action("Z01_MasterData_Discard", discard); - const addButton = action("Z01_MasterData_AddRow", addRow); - const deleteButton = action("Z01_MasterData_DeleteRow", toggleDelete); - const resetButton = action("Z01_MasterData_Reset", () => void reset()); - const draftStatus = el("span", { className: "z01-master__draft-status" }); - const actions = el("div", { - className: "z01-master__toolbar", - children: [saveButton, discardButton, addButton, deleteButton, resetButton, draftStatus], - }); const root = el("section", { className: "z01-master__panel", children: [ el("div", { children: [title, titleKey] }), notice, banner, - actions, toolbar, detail, grid, @@ -180,13 +170,6 @@ export function buildRowsView(): RowsView { detail.hidden = true; notice.hidden = true; banner.hidden = true; - actions.hidden = true; - // 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장). - window.addEventListener("beforeunload", (event) => { - if (!draft?.count || !root.isConnected) return; - event.preventDefault(); - event.returnValue = L("Z01_MasterData_Unsaved"); - }); function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -221,65 +204,15 @@ export function buildRowsView(): RowsView { if (!data) return; grid.classList.remove("is-loading"); last = data; - const edit = source.edit; - if (edit && data.revision !== undefined) { - // 고친 것이 남은 초안은 쪽을 넘겨도 지킴 — 그새 판이 바뀌었으면 stale 로 알림(조용히 안 버림). - if (draft?.scope !== edit.scope || !draft.count) { - draft = new BaseDraft(edit.scope, data.revision); - draft.onChange = drawActions; - } - stale = draft.revision !== data.revision; - } - picked = null; + selected = null; detail.hidden = true; draw(); } - const rowKey = (row: Row, index: number): string => - row["@id"] == null ? `#${index}` : String(row["@id"]); - - const changedColumns = (row: Row): string[] => - Array.isArray(row.changed_columns) ? (row.changed_columns as string[]) : []; - - /** 고른 서버 줄 — 저장 전 새 줄·열쇠 없는 줄이면 없음. */ - function pickedRow(): Row | undefined { - if (!picked || picked.added) return undefined; - const key = picked.row; - return last?.rows.find((row) => row["@id"] != null && String(row["@id"]) === key); - } - - /** 새 줄에 적은 글자를 수로 읽을지 — 같은 열 다른 줄 값의 꼴을 따름(코드 칸이 수로 바뀌지 않게). */ - const sample = (column: string): unknown => - last?.rows.find((row) => row[column] != null)?.[column]; - - function drawActions(): void { - // 서버가 판(revision)을 준 표만 고침 — 안 주는 표(공종 축 등 DB 밖)는 단추를 안 세움. - actions.hidden = !draft; - const count = draft?.count ?? 0; - const row = pickedRow(); - saveButton.disabled = count === 0 || stale; - discardButton.disabled = count === 0 && !stale; - addButton.disabled = !draft || stale; - deleteButton.disabled = !draft || !(picked?.added || row); - const undo = Boolean(row && draft?.deleted.has(String(row["@id"]))); - deleteButton.querySelector(".ui-btn__label")!.textContent = L( - undo ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow", - ); - // 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 판이 갈려 초안이 버려지니 먼저 저장·버리게 함. - resetButton.disabled = !row || count > 0 || !(changedColumns(row).length || row.is_added); - resetButton.title = count > 0 ? L("Z01_MasterData_ResetBlocked") : ""; - draftStatus.classList.toggle("is-warning", stale); - draftStatus.textContent = stale - ? L("Z01_MasterData_Draft_Stale") - : count && draft - ? `${L("Z01_MasterData_Draft_Edits")} ${draft.edits.size} · ${L("Z01_MasterData_Draft_Added")} ${draft.added.length} · ${L("Z01_MasterData_Draft_Deleted")} ${draft.deleted.size} — ${L("Z01_MasterData_Draft_Pending")}` - : L("Z01_MasterData_Draft_None"); - } - function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); const marks: [string, string][] = []; - if (source?.edit && meta.editable?.includes(column.key)) { + if (source?.save && meta.editable?.includes(column.key)) { marks.push(["✎", L("Z01_MasterData_Edit_Can")]); } const reason = meta.locked?.[column.key]; @@ -353,11 +286,39 @@ export function buildRowsView(): RowsView { const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); - // 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐. - for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added)); - meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index))); + meta.rows.forEach((row, index) => { + const texts = rowCells(row, columns); + const tr = el("tr"); + columns.forEach((column, i) => { + const info = cellInfo(meta, row, column.key); + const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); + if (info.state) { + // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). + td.classList.add("is-overridden", `is-${info.state.replaceAll("_", "-")}`); + if (info.state !== "edited") { + // 뜻 바뀜은 값 어긋남보다 나쁨 — 표시부터 더 세게(2026-09-16 브레인). + td.textContent = `${info.state === "meaning_changed" ? "⛔" : "⚠"} ${texts[i]}`; + tr.classList.add(info.state === "meaning_changed" ? "has-danger" : "has-warning"); + } + td.title = stateText(info, column); + } + const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; + if (canEdit) td.classList.add("is-editable"); + if (selected?.index === index && selected.key === column.key) + td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); + selectCell(index, column); + td.classList.add("is-selected"); + if (canEdit) startEdit(td, index, column); + }); + tr.append(td); + }); + body.append(tr); + }); grid.replaceChildren( - body.children.length + meta.rows.length ? el("table", { className: "z01-master__grid", children: [el("thead", { children: [headRow] }), body], @@ -370,100 +331,25 @@ export function buildRowsView(): RowsView { pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; first.disabled = prev.disabled = state.page <= 1; next.disabled = end.disabled = state.page >= pages; - drawActions(); - } - - const isPicked = (row: string, added: boolean, column: string): boolean => - picked?.row === row && picked.added === added && picked.column === column; - - function serverRow(meta: MasterRows, columns: MasterColumn[], row: Row, index: number) { - const key = rowKey(row, index); - const texts = rowCells(row, columns); - const changed = changedColumns(row); - const deleted = draft?.deleted.has(key) ?? false; - const tr = el("tr"); - if (row.is_added) tr.classList.add("is-added"); - if (deleted) tr.classList.add("is-deleted"); - columns.forEach((column, i) => { - const info = cellInfo(meta, row, column.key); - const stored = row[column.key]; - const pending = draft?.edit(key, column.key); - const text = pending ? cellText(pending.value, column.unit) : texts[i]; - const td = el("td", { text, attrs: { title: text } }); - // 고친 칸 표시 — 저장 전(초안) · 저장했고 초기값과 다름(서버 changed_columns) - if (pending) td.classList.add("is-draft"); - else if (changed.includes(column.key)) td.classList.add("is-changed"); - const canEdit = Boolean(draft) && info.editable && row["@id"] != null && !deleted; - if (canEdit) td.classList.add("is-editable"); - if (isPicked(key, false, column.key)) td.classList.add("is-selected"); - td.addEventListener("click", () => { - if (td.querySelector("input")) return; - pick(td, { row: key, added: false, column: column.key }); - if (!canEdit) return; - startEdit(td, pending ? pending.value : stored, stored ?? sample(column.key), (value) => - draft?.setEdit(key, column.key, value, stored), - ); - }); - tr.append(td); - }); - return tr; - } - - function addedRow(meta: MasterRows, columns: MasterColumn[], added: AddedRow) { - const tr = el("tr", { className: "is-new", attrs: { title: L("Z01_MasterData_Row_New") } }); - for (const column of columns) { - const value = added.data[column.key]; - const td = el("td", { text: cellText(value, column.unit) }); - // 새 줄은 식으로 나오는 칸만 빼고 다 채움 — 열쇠·이름도 사람이 넣어야 줄이 섬. - const canEdit = !meta.formula?.[column.key]; - if (canEdit) td.classList.add("is-editable"); - if (isPicked(added.tmp, true, column.key)) td.classList.add("is-selected"); - td.addEventListener("click", () => { - if (td.querySelector("input")) return; - pick(td, { row: added.tmp, added: true, column: column.key }); - if (!canEdit) return; - startEdit( - td, - value, - sample(column.key), - (next) => draft?.setAdded(added.tmp, column.key, next), - true, - ); - }); - tr.append(td); - } - return tr; - } - - function pick(td: HTMLElement, next: Picked): void { - grid.querySelector("td.is-selected")?.classList.remove("is-selected"); - td.classList.add("is-selected"); - picked = next; - showDetail(); - drawActions(); } /** - * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 저장 전 고침 · 초기값과 다름 · 식과 값 넣은 식. - * `failure` = 방금 고침이 안 들어간 까닭 — 토스트만 뜨고 사라지면 사람은 들어간 줄 앎(브레인 ③). + * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. + * `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③). */ - function showDetail(failure?: string): void { - const at = picked; - const column = last?.columns.find((each) => each.key === at?.column); - if (!last || !at || !column) return; - const row = at.added - ? (draft?.added.find((each) => each.tmp === at.row)?.data ?? {}) - : (pickedRow() ?? last.rows[Number(at.row.slice(1))] ?? {}); + function selectCell(index: number, column: MasterColumn, failure?: string): void { + if (!last) return; + selected = { index, key: column.key }; + const row = last.rows[index]; const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; - const line = (text: string, warn = false) => { - const span = el("span", { className: "z01-master__detail-line", text }); - span.classList.toggle("is-warning", warn); - return span; - }; - if (failure) lines.push(line(fill("Z01_MasterData_Edit_NotSaved", failure), true)); - if (at.added) lines.push(line(L("Z01_MasterData_Row_New"))); - else if (source?.edit) { + const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (failure) { + const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure)); + failed.classList.add("is-warning"); + lines.push(failed); + } + if (source?.save) { lines.push( line( info.editable @@ -471,14 +357,30 @@ export function buildRowsView(): RowsView { : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, ), ); - const pending = draft?.edit(at.row, column.key); - if (pending) { - const value = valueWithUnit(pending.value, column.unit); - lines.push(line(fill("Z01_MasterData_Cell_Draft", value))); + } + if (info.state) { + const stateLine = line(stateText(info, column)); + if (info.state !== "edited") stateLine.classList.add("is-warning"); + lines.push(stateLine); + // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). + // 뜻이 바뀐 줄에서 「새 원본 값으로 받기」는 위험 — 글자를 가르고 한 번 더 물음(브레인 ③). + const target = + info.state === "meaning_changed" + ? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit)) + : info.state === "source_changed" + ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) + : info.state === "orphan" + ? null + : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); + if (target && source?.save) { + lines.push( + createButton({ + label: target, + variant: info.state === "meaning_changed" ? "danger" : "ghost", + onClick: () => void drop(index, column, info), + }), + ); } - if (changedColumns(row).includes(column.key)) - lines.push(line(L("Z01_MasterData_Cell_Changed"))); - if (row.is_added) lines.push(line(L("Z01_MasterData_Row_Added"))); } if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); if (info.filled) lines.push(line(info.filled)); @@ -486,38 +388,31 @@ export function buildRowsView(): RowsView { detail.hidden = false; } - /** - * 칸 고치기 — Enter·칸 나감 = 초안에 넣음(서버엔 안 감) · Esc = 그대로. - * `typeOf` = 수로 읽을지 가르는 본보기 값 · `allowEmpty` = 비우면 칸을 뺌(새 줄). - */ - function startEdit( - td: HTMLElement, - current: unknown, - typeOf: unknown, - apply: (value: unknown) => void, - allowEmpty = false, - ): void { + function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { + if (!last) return; + const current = last.rows[index][column.key]; const input = el("input", { className: "ui-input z01-master__cell-input" }); input.value = current == null ? "" : String(current); td.replaceChildren(input); input.focus(); input.select(); let finished = false; - const finish = (keep: boolean): void => { + const finish = (save: boolean): void => { if (finished) return; finished = true; - const empty = allowEmpty && input.value.trim() === ""; - const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf); + const value = save ? parseCellInput(input.value, current) : current; if (value === undefined) { const why = fill("Z01_MasterData_Edit_Invalid", input.value); showToast(why, "error"); draw(); - showDetail(why); + selectCell(index, column, why); return; } - if (keep) apply(value); - draw(); - showDetail(); + if (value === current) { + draw(); + return; + } + void commit(index, column, value); }; input.addEventListener("keydown", (event) => { if (event.key === "Enter") finish(true); @@ -526,87 +421,59 @@ export function buildRowsView(): RowsView { input.addEventListener("blur", () => finish(true)); } - function addRow(): void { - draft?.addRow(); - draw(); - } - - function toggleDelete(): void { - if (!draft || !picked) return; - if (picked.added) { - draft.removeAdded(picked.row); - picked = null; - detail.hidden = true; - } else if (pickedRow()) { - draft.toggleDelete(picked.row); + /** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */ + async function drop(index: number, column: MasterColumn, info: CellInfo): Promise { + if (info.state === "meaning_changed") { + const ok = await showConfirmDialog( + `${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`, + L("Z01_MasterData_Edit_DropConfirm"), + ); + if (!ok) return; } - draw(); + await commit(index, column, null); } - function discard(): void { - draft?.clear(); - stale = false; - void load(); - } - - /** [저장] — 초안 한 벌을 보냄. 409 면 초안을 지키고 알림(다른 곳에서 먼저 저장함). */ - async function save(): Promise { - const edit = source?.edit; - const active = draft; - if (!edit || !active?.count) return; - showLoadingOverlay(); + /** 저장 — 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; try { - await edit.save(active.payload()); - active.clear(); - showToast(L("Z01_MasterData_Saved"), "success"); - void load(); + const updated = await from.save(String(rowId), { [column.key]: value }); + if (from !== source || table !== last) return; + table.rows[index] = updated; + draw(); + selectCell(index, column); + showToast( + L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), + "success", + ); } catch (error) { - if (error instanceof ApiError && error.status === 409) stale = true; - showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); - drawActions(); - } finally { - hideLoadingOverlay(); + const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"); + showToast(why, "error"); + if (from !== source || table !== last) return; + draw(); + selectCell(index, column, why); } } - /** [초기값으로] — 고른 줄을 seed 로(추가한 줄은 지워짐) · 바로 저장이라 한 번 물음. */ - async function reset(): Promise { - const edit = source?.edit; - const row = pickedRow(); - if (!edit || !row || draft?.count) return; - const ok = await showConfirmDialog(L("Z01_MasterData_ResetConfirm"), L("Z01_MasterData_Reset")); - if (!ok) return; - showLoadingOverlay(); - try { - await edit.reset([String(row["@id"])]); - showToast(L("Z01_MasterData_ResetDone"), "success"); - void load(); - } catch (error) { - showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } - } - - function show(next: RowsSource): void { - source = next; + function show(picked: RowsSource): void { + source = picked; state.page = 1; state.sort = null; state.filters = {}; filterBox.replaceChildren(); - title.textContent = next.title; - titleKey.textContent = next.key; + title.textContent = picked.title; + titleKey.textContent = picked.key; notice.hidden = true; banner.hidden = true; - search.root.hidden = next.searchable === false; + search.root.hidden = picked.searchable === false; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; last = null; - picked = null; - draft = null; - stale = false; - actions.hidden = true; + selected = null; grid.replaceChildren(); void load(); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 3a3f6860..0e958c6d 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -12,22 +12,25 @@ import { t as L } from "@ui/ui_template_locale"; import { fetchBasePrices, fetchMasterRows, - resetBasePrices, - saveBasePrices, + fetchOverrides, type MasterGroup, + type MasterRows, + type RowsQuery, + saveBasePrice, VALUES_TABLE_ID, - type WorkItemAxis, } from "./Z01_MasterData_Api_Fetch"; -import { groupKinds, type ServedKind } from "./Z01_MasterData_UI_Cells"; +import { + groupKinds, + valueWithUnit, + type OverrideState, + type ServedKind, +} from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; -const AXES: WorkItemAxis[] = ["forest", "const"]; - export function buildSidePanel( groups: MasterGroup[], kinds: ServedKind[], view: RowsView, - openWorkItems: (axis: WorkItemAxis) => void, ): HTMLElement { const panel = el("div", { className: "z01-master__side" }); const activate = (button: HTMLElement): void => { @@ -56,32 +59,29 @@ export function buildSidePanel( title: `${box.label} › ${served.label}`, key: `base-prices/${served.kind}`, load: (query) => fetchBasePrices(served.kind, query), - edit: { - scope: `base:${served.kind}`, - save: (body) => saveBasePrices(served.kind, body), - reset: (rowKeys) => resetBasePrices(served.kind, rowKeys), - }, + save: (rowId, values) => saveBasePrice(served.kind, rowId, values), }); }); grid.append(button); } return { label: box.label, grid }; }); - - // 공종 편집 — 구성 줄을 고치고 「입력 필요」 공종을 메움(PLAN 1-4). - const workGrid = el("div", { className: "z01-master__base-grid" }); - for (const axis of AXES) { - const button = el("button", { - className: "z01-master__base", - attrs: { type: "button" }, - text: L(axis === "forest" ? "Z01_WorkItem_Axis_Forest" : "Z01_WorkItem_Axis_Const"), + const overridesButton = el("button", { + className: "z01-master__base z01-master__base--wide", + attrs: { type: "button" }, + text: L("Z01_MasterData_Overrides"), + }); + overridesButton.addEventListener("click", () => { + activate(overridesButton); + view.show({ + title: L("Z01_MasterData_Overrides"), + key: "overrides", + searchable: false, + // 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임. + load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))), }); - button.addEventListener("click", () => { - activate(button); - openWorkItems(axis); - }); - workGrid.append(button); - } + }); + boxes[0]?.grid.append(overridesButton); const tree = el("nav", { className: "z01-master__tree" }); for (const group of groups) { @@ -132,13 +132,54 @@ export function buildSidePanel( panel.append( ...boxes.map((box) => section(box.label, box.grid)), - section(L("Z01_WorkItem_Title"), workGrid), section(L("Z01_MasterData_Tree"), tree), ); attachCollapsible(panel); return panel; } +const STATE_LABEL: Record[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 24640741..b62321c4 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -315,204 +315,3 @@ width: 6em; text-align: right; } - -/* ── 고치기 — 저장 전 초안 · 저장된 고침(초기값과 다름) · 새 줄 · 지울 줄 (PLAN 1-4) ── */ -.z01-master__grid td.is-draft, -.z01-wi__field.is-draft { - background: color-mix(in srgb, var(--color-warning) 24%, var(--color-canvas)); - font-weight: var(--font-weight-bold); -} - -.z01-master__grid td.is-changed, -.z01-wi__field.is-changed { - background: var(--color-mist-violet); - color: var(--color-accent); -} - -.z01-master__grid tr.is-new td { - background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas)); -} - -.z01-master__grid tr.is-added td:first-child { - box-shadow: inset 4px 0 0 var(--color-success); -} - -.z01-master__grid tr.is-deleted td { - color: var(--color-text-muted); - text-decoration: line-through; -} - -.z01-master__draft-status { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.z01-master__draft-status.is-warning { - color: var(--color-danger); - font-weight: var(--font-weight-bold); -} - -/* ── 공종 편집 — 왼쪽 목록 / 오른쪽 머리·구성 줄·원문 ── */ -.z01-master__panel.z01-wi { - display: grid; - grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); - align-items: start; - gap: var(--spacing-16); -} - -.z01-wi__side { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - position: sticky; - top: 0; -} - -.z01-wi__list { - display: flex; - flex-direction: column; - max-height: calc(100vh - 260px); - overflow: auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); -} - -.z01-wi__list.is-loading { - opacity: 0.5; -} - -.z01-wi__item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: var(--spacing-4) var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-bottom: 1px solid var(--color-border); - background: transparent; - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); - text-align: left; - cursor: pointer; -} - -.z01-wi__item:hover { - background: var(--color-paper); -} - -.z01-wi__item.is-active { - background: var(--color-mist-violet); - color: var(--color-accent); -} - -.z01-wi__item-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.z01-wi__status { - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.z01-wi__status.is-needs-input, -.z01-wi__status.is-partial, -.z01-wi__unmatched { - color: var(--color-danger); - font-weight: var(--font-weight-bold); -} - -.z01-wi__status.is-reviewed { - color: var(--color-success); -} - -.z01-wi__editor { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; -} - -.z01-wi__head { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: var(--spacing-8) var(--spacing-16); - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - font-size: var(--text-body-sm); -} - -.z01-wi__head:empty { - display: none; -} - -.z01-wi__field { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px var(--spacing-4); - border-radius: var(--radius-sm); -} - -.z01-wi__label { - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.z01-wi__heading { - margin: 0; - color: var(--color-text); - font-size: var(--text-body); -} - -.z01-wi__variant { - margin: var(--spacing-8) 0 var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.z01-wi__grid td { - padding: 2px var(--spacing-4); -} - -.z01-wi__grid input, -.z01-wi__grid select { - min-width: 4em; - padding: 2px var(--spacing-4); - font-size: 12px; -} - -.z01-wi__grid select { - min-width: 6em; -} - -.z01-wi__grid input[type="number"] { - width: 6em; -} - -.z01-wi__finder { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border: 2px solid var(--color-accent); - border-radius: var(--radius-cards); - background: var(--color-surface); -} - -.z01-wi__found { - display: flex; - flex-direction: column; - max-height: 240px; - overflow: auto; -} - -.z01-wi__notes { - margin: 0; - padding-left: 1.2em; - font-size: var(--text-body-sm); -} diff --git a/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts b/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts deleted file mode 100644 index 51ee3e9f..00000000 --- a/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts +++ /dev/null @@ -1,409 +0,0 @@ -/* ============================================================================= - * Z01_MasterData_UI_WorkItem.ts - * 공종 편집 — 왼쪽 목록(상태·검색) / 머리 / 구성 줄 표 / 품셈 원문 표+[주] (PLAN 1-4) - * - * 자동 추출이 못 읽은 공종(「입력 필요」·「일부 못 맞춤」)을 관리자가 화면에서 메움. - * ⚠ 자동저장 금지 — 고친 것은 초안(sessionStorage `z01-draft:work_item:{key}`)에 쌓고 - * [저장] 한 번에 머리 + 전체 줄을 보냄 · 판(revision)이 다르면 409 → 초안을 지키고 알림. - * [초기값으로]·[검수 완료]도 서버에 바로 씀 — 화면을 잠그는 오버레이는 이 셋에만(CLAUDE.md 5장). - * ========================================================================== */ - -import { - createButton, - createInputField, - createSelectField, - el, - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { - ApiError, - fetchWorkItem, - fetchWorkItems, - resetWorkItem, - saveWorkItem, - type WorkItemAxis, - type WorkItemDetail, - type WorkItemHead, - type WorkItemList, - type WorkItemListRow, - type WorkItemStatus, -} from "./Z01_MasterData_Api_Fetch"; -import { latestOnly, pageCount } from "./Z01_MasterData_UI_Cells"; -import { - freshBody, - hasDraft, - isDirty, - readDraft, - same, - workItemPayload, - writeDraft, - type WorkItemBody, -} from "./Z01_MasterData_UI_Draft"; -import { renderSource, renderWorkRows } from "./Z01_MasterData_UI_WorkItem_Rows"; - -type LocaleKey = Parameters[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 deleted file mode 100644 index 121ae081..00000000 --- a/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts +++ /dev/null @@ -1,374 +0,0 @@ -/* ============================================================================= - * Z01_MasterData_UI_WorkItem_Rows.ts - * 공종 편집 아래쪽 — 구성 줄 표(갈래별) · 자원 찾기(기초단가 검색) · 품셈 원문 표 + [주](읽기 전용) - * - * 칸을 고치면 초안(`WorkItemBody`)만 바꿈 — 서버엔 [저장] 때 전체 줄을 한 번에 보냄(PLAN 1-0). - * 못 맞춘 줄(resource_key 없음)은 또렷이 — 자원 찾기로 기초단가 줄을 골라 이음. - * ========================================================================== */ - -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { - fetchBasePrices, - type ResourceKind, - type WorkItemDetail, - type WorkItemRow, -} from "./Z01_MasterData_Api_Fetch"; -import { cellText, latestOnly } from "./Z01_MasterData_UI_Cells"; -import { - addRow, - changedFields, - groupByVariant, - type DraftRow, - type WorkItemBody, -} from "./Z01_MasterData_UI_Draft"; - -type LocaleKey = Parameters[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 c62360a5..23c8d85b 100644 --- a/Z01_MasterData/Z01_MasterData_WorkItems.py +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -7,15 +7,11 @@ — 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐. `@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄. ⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림. - -공종 편집(DB 정본 · 2026-09-18 PLAN 1-0) — 맨 아래 「공종 편집」 절. `master_work_item`·`_row` 를 -고치는 규칙만 순수 함수로(DB 없이 시험) · SQL 은 `Z01_MasterData_Repository_WorkItems`. """ from __future__ import annotations import json -from decimal import Decimal from functools import lru_cache from pathlib import Path from typing import Any @@ -296,131 +292,3 @@ def extra(kind: str) -> dict[str, Any]: "units": len(rows), }, } - - -# ── 공종 편집 — DB 표 `master_work_item`·`master_work_item_row` 를 고치는 규칙(PLAN 1-0 계약) ── - -STATUSES = ("auto_ok", "partial", "needs_input", "not_item", "reviewed") -#: 관리자가 고치는 칸 — [초기값으로] 도 이 칸만 seed 에서 되살림 -HEAD_FIELDS = ("name", "basis_quantity", "basis_unit", "status") -ROW_FIELDS = ( - "variant", - "resource_kind", - "resource_key", - "resource_name", - "resource_spec", - "amount", - "amount_unit", - "alternative_amount", - "group_ratio_pct", - "raw_cell", - "pum_table_id", -) -_HIDDEN = ("id", "work_item_id", "seed", "source", "notes", "is_deleted") - - -def revision_scope(key: str) -> str: - return f"work_item:{key}" - - -def _same(a: Any, b: Any) -> bool: - """칸 값이 같은가 — 숫자는 값으로(DB `1.500000` = 요청 `1.5` = seed `1.5`) · 빈 글자 = NULL.""" - numbers = (int, float, Decimal) - if isinstance(a, numbers) and isinstance(b, numbers): - return Decimal(str(a)) == Decimal(str(b)) - return (None if a == "" else a) == (None if b == "" else b) - - -def changed_columns(current: dict[str, Any], seed: dict[str, Any] | None, fields) -> list[str]: - """초기값과 다른 칸 — 관리자 추가 줄(seed 없음)은 빈 목록(`is_added` 로 드러남).""" - if seed is None: - return [] - return [f for f in fields if f in seed and not _same(current.get(f), seed[f])] - - -def detail(head: dict[str, Any], rows: list[dict[str, Any]], revision: int) -> dict[str, Any]: - """GET 응답 — 머리(+revision) · 구성 줄 · 원문 표 · [주]. seed 는 안 내보내고 고친 칸 이름만.""" - seed = head.get("seed") - return { - "item": { - **{k: v for k, v in head.items() if k not in _HIDDEN}, - "revision": revision, - "changed_columns": changed_columns(head, seed, HEAD_FIELDS), - }, - "rows": [ - { - **{k: v for k, v in row.items() if k not in _HIDDEN or k == "id"}, - "is_added": row.get("seed") is None, - "changed_columns": changed_columns(row, row.get("seed"), ROW_FIELDS), - } - for row in rows - ], - "source": head.get("source") or [], - "notes": head.get("notes") or [], - } - - -def check_rows( - rows: list[dict[str, Any]], existing_ids: set[int], known_keys: set[str] -) -> list[str]: - """[저장] 서버 검사 중 DB 와 맞춰 볼 것 — 모양(숫자 ≥0 · 종류 · 모르는 칸)은 요청 모델이 봄.""" - errors, seen = [], set() - for number, row in enumerate(rows, 1): - row_id = row.get("id") - if row_id is not None: - if row_id not in existing_ids: - errors.append(f"{number}번째 줄: 이 공종에 없는 줄(id {row_id})") - elif row_id in seen: - errors.append(f"{number}번째 줄: 같은 줄(id {row_id})이 두 번 옴") - seen.add(row_id) - key = row.get("resource_key") - if key is not None and key not in known_keys: - errors.append(f"{number}번째 줄: 기초단가에 없는 자원 열쇠 {key}") - return errors - - -def plan_rows( - existing: list[dict[str, Any]], incoming: list[dict[str, Any]] -) -> dict[str, list[Any]]: - """전체 줄 보내기 → 쓸 것. existing = 지금 살아 있는 줄 · incoming 순서 = sort_order. - - id 있음 → 수정(바뀐 줄만) · id 없음 → 추가 · 빠진 줄 → 초기값 줄은 숨김(`is_deleted` · - [초기값으로] 가 살림) · 관리자 추가 줄은 지움. - """ - by_id = {row["id"]: row for row in existing} - updates, inserts = [], [] - for order, row in enumerate(incoming): - values = {f: row.get(f) for f in ROW_FIELDS} | {"sort_order": order} - old = by_id.get(row.get("id")) - if old is None: - inserts.append(values) - elif any(not _same(old.get(f), v) for f, v in values.items()): - updates.append({"id": old["id"], **values}) - kept = {row.get("id") for row in incoming} - gone = [row for row in existing if row["id"] not in kept] - return { - "updates": updates, - "inserts": inserts, - "hide": [row["id"] for row in gone if row.get("seed") is not None], - "drop": [row["id"] for row in gone if row.get("seed") is None], - } - - -def reset_plan(head: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: - """[초기값으로] — 머리·줄을 seed 로(seed 에 있는 칸만) · 숨긴 초기값 줄도 살림 · 추가 줄은 지움. - - rows = 숨긴 줄까지 전부. 쓰기 모양은 `plan_rows` 와 같음(+ `head`). - """ - seed = head.get("seed") or {} - restore = (*ROW_FIELDS, "sort_order") - return { - "head": {f: seed[f] for f in HEAD_FIELDS if f in seed}, - "updates": [ - {"id": row["id"], **{f: row["seed"][f] for f in restore if f in row["seed"]}} - for row in rows - if row.get("seed") is not None - ], - "inserts": [], - "hide": [], - "drop": [row["id"] for row in rows if row.get("seed") is None], - } diff --git a/main.py b/main.py index 3d48f738..85f2344a 100644 --- a/main.py +++ b/main.py @@ -76,7 +76,6 @@ from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( router as b09_material_prices_router, ) from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router -from Z01_MasterData.Z01_MasterData_Router_WorkItems import router as z01_work_items_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -660,7 +659,6 @@ app.include_router(b09_material_prices_router, dependencies=protected_with_compa # Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01). system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(z01_master_data_router, dependencies=system_admin_only) -app.include_router(z01_work_items_router, dependencies=system_admin_only) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제04장_나무베기/4-06_벌목부_작업안전_보조.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제04장_나무베기/4-06_벌목부_작업안전_보조.md index 7685bf36..6470a6f2 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제04장_나무베기/4-06_벌목부_작업안전_보조.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제04장_나무베기/4-06_벌목부_작업안전_보조.md @@ -5,12 +5,12 @@ PDF쪽: 63 인쇄쪽: 51 시작표지: 4-6. 벌목부 작업안전 보조 끝표지: 제5장 식재 및 녹화 -상태: 검증대기 +상태: 확정 작성: sub_laptop_4 -검증: +검증: sub_laptop_3 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: 578b6539037cdfe4e41314fc02cfb862cc25403fd3af290ee7a50344633ffd87 +확정지문: f920d4aa3a93ed51c6ef4e524dfe3adf88c4ef3a0611656724179bbd6f2fd60e --- ## 4-6. 벌목부 작업안전 보조 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/0-00_장머리.md index cb4af6b4..c8679992 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/0-00_장머리.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/0-00_장머리.md @@ -9,7 +9,7 @@ PDF쪽: 64 작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-01_굴취작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-01_굴취작업.md index 5cc72615..e5c09133 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-01_굴취작업.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-01_굴취작업.md @@ -5,12 +5,12 @@ PDF쪽: 64~66 인쇄쪽: 52~54 시작표지: 5-1. 굴취작업 끝표지: 5-2. 뿌리돌림 -상태: 검증대기 +상태: 확정 작성: sub_laptop_4 -검증: +검증: sub_laptop_3 원천: 글자층 -기계검사: -확정지문: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 +확정지문: 5df7616cea2cf66fa738d38c91f65b1dd3feb5754d1fa9e9729a5705bf8f503b --- ## 5-1. 굴취작업 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md index 45e4e755..89280fd7 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-11_사초심기.md @@ -5,12 +5,12 @@ PDF쪽: 73~74 인쇄쪽: 61~62 시작표지: 5-11. 사초심기 끝표지: 5-12. 떼붙임(재배잔디) -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 5a7ced17f934551f6835b729efbbca64c49255527dd30e659b36398a5247f64b --- ## 5-11. 사초심기 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md index a8dca10d..d5d1b57a 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md @@ -5,12 +5,12 @@ PDF쪽: 74 인쇄쪽: 62 시작표지: 5-12. 떼붙임(재배잔디) 끝표지: 5-13. 떼심기 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 3caa9728cf251862784010b5d354a8ee32b3760c0515c559ca27a3c412b0d507 --- ## 5-12. 떼붙임(재배잔디) diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md index ed50f117..0f624db4 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-13_떼심기.md @@ -5,12 +5,12 @@ PDF쪽: 74~75 인쇄쪽: 62~63 시작표지: 5-13. 떼심기 끝표지: 5-14. 새심기 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 704e86bbc40d9419027ff16381efc4d172de112f650c8d3f4e7cd715ede7400b --- ## 5-13. 떼심기 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md index 18bba5ad..a6ae68b8 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-14_새심기.md @@ -5,12 +5,12 @@ PDF쪽: 75 인쇄쪽: 63 시작표지: 5-14. 새심기 끝표지: 5-15. 바자얽기 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 8bbd0471eac31be61aa7c441112992e0b972a0f530c84bf9cce9077d398775b6 --- ## 5-14. 새심기 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md index 28ce0e4d..ce30eaf2 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-15_바자얽기.md @@ -5,12 +5,12 @@ PDF쪽: 75~76 인쇄쪽: 63~64 시작표지: 5-15. 바자얽기 끝표지: 5-16. 선떼붙이기공 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: ad33c43714431682f3a1295ca6c8ba8627ee39e0eeac60800cb34aefc9b18930 --- ## 5-15. 바자얽기 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md index a139b60e..36dd05af 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-16_선떼붙이기공.md @@ -5,12 +5,12 @@ PDF쪽: 76 인쇄쪽: 64 시작표지: 5-16. 선떼붙이기공 끝표지: 5-17. 조공 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: f591ab94c6fa59fa75432d7d8534c7c032dd3ecbcdfba07ed9e6cb0067b99248 --- ## 5-16. 선떼붙이기공 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md index af03ac22..3be8bf0d 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-17_조공.md @@ -5,12 +5,12 @@ PDF쪽: 77 인쇄쪽: 65 시작표지: 5-17. 조공 끝표지: 5-18. 씨뿌리기(줄) -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: b52381a9a84d85323dd601db56f4f1f77abf6eabf16805620f16f233bfeb6236 --- ## 5-17. 조공 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md index 9c4aaf9d..621e43b9 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-18_씨뿌리기(줄).md @@ -5,12 +5,12 @@ PDF쪽: 77~78 인쇄쪽: 65~66 시작표지: 5-18. 씨뿌리기(줄) 끝표지: 5-19. 표토 절취 및 정지 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 2e152984431398dec1dd5ff1c02d276f748d53271e56f83d2f71979b63670f52 --- ## 5-18. 씨뿌리기(줄) diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md index 458edc82..37b53aed 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-19_표토_절취_및_정지.md @@ -5,12 +5,12 @@ PDF쪽: 78 인쇄쪽: 66 시작표지: 5-19. 표토 절취 및 정지 끝표지: 5-20. 표토관리 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 461a24d9a252d751166c753b3c08153e6390c1c98192616a86a4d3497ee711c8 --- ## 5-19. 표토 절취 및 정지 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md index 49fa1a01..c732be29 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-20_표토관리.md @@ -5,12 +5,12 @@ PDF쪽: 78 인쇄쪽: 66 시작표지: 5-20. 표토관리 끝표지: 5-21. 표토이식 -상태: 검증대기 +상태: 확정 작성: sub_laptop_2 -검증: +검증: sub_laptop_4 원천: 글자층 기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 -확정지문: +확정지문: 1b2ce1aaae7b7e38d580415e4205305671d3eb1baa432b6588edfd9008cef188 --- ## 5-20. 표토관리 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md index 96cb2781..e7e53eb5 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-21_표토이식.md @@ -5,10 +5,28 @@ PDF쪽: 79 인쇄쪽: 67 시작표지: 5-21. 표토이식 끝표지: 5-22. 평떼 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-21. 표토이식 + +(인/㎡) + +| 구분 | 보통인부(인) | 비고 | +|---|---|---| +| 채취 | 0.34 | | +| 운반 | 0.26 | | +| 붙이기 | 0.47 | | + +[주] +① 사방사업에서 기존 표토를 채취하여 보관한 후 이식하여 주변 식생과 조화를 이루게 하는 지역에 적용한다. +② 채취품은 1.2m 이하의 관목류와 초본류를 채취하는데 사용되며 그 이상은 별도 품을 적용한다. +③ 표토의 두께는 0.2m를 기준으로 한다. 그 이상은 장비와 병용하여 사용한다. +④ 운반 30m를 기준으로 한 품이며 그 이상은 무게(떼 무게 적용)를 감안한 소운반을 별도 계상한다. +⑤ 붙이기 품에는 복토 및 식재지 면고르기, 잡관목 제거품은 포함되어 있지 않다. +⑥ 표토의 채취 시 채취지역을 분산시켜 자연식생의 훼손을 최소화한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md index 39647bbb..66591a83 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-22_평떼.md @@ -5,10 +5,54 @@ PDF쪽: 79~80 인쇄쪽: 67~68 시작표지: 5-22. 평떼 끝표지: 5-23. 줄떼 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-22. 평떼 + +### 5-22-1. 떼 구입 및 운반 : 별도 계상 + +\- 규격 및 소요 매수 : 0.3×0.3, 11매/㎡ + +### 5-22-2. 평떼 붙임 + +(㎡당) + +| 구분 | 단위 | 적용 | 비고 | +|---|---|---|---| +| 조경공 | 인 | 0.0099 | | +| 보통인부 | 인 | 0.0231 | | + +### 5-22-3. 떼꽂이 제작 및 설치 + +(㎡당) + +| 구분 | 단위 | 적용 | 비고 | +|---|---|---|---| +| 보통인부 | 인 | 0.011 | 1일1인 1,000개,
㎡당 3.3개 기준 | + +[주] +평떼(보통인부 0.06인)품에는 떼꽂이 제작 및 설치가 포함되어 있어 비교ㆍ검토 후 적용한다. + +### 5-22-4. 평떼 시비 + +(일당) + +| 구분 | 규격 | 단위 | 수량 | 시공량(㎡) | +|---|---|---|---|---| +| 조경공 | | 인 | 2 | 22,500 | +| 보통인부 | | | 1 | | +| 트럭 | 2.5ton | 대 | 1 | | + + +[주] +① 본 품은 화학비료의 살포가 300~700kg/10,000㎡인 경우 기준이다. +② 현장조건, 살포조건에 따라 살포량이 다를 때는 본 품의 20%범위 내에서 증감할 수 있다. +③ 비료량은 별도 계상한다. +④ 「건설공사 표준품셈 유지관리부문, 1-2-5 관목 전정(2025)」 참고한다. + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md index 7766c8bc..281f7449 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-23_줄떼.md @@ -5,10 +5,33 @@ PDF쪽: 80 인쇄쪽: 68 시작표지: 5-23. 줄떼 끝표지: 5-24. 씨앗뿜어붙이기 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-23. 줄떼 + +### 5-23-1. 떼 구입 및 운반 : 별도 계상 + +\- 규격 및 소요 매수 : 0.3×0.3(30㎝ 간격), 3.33매/㎡ + +### 5-23-2. 떼붙임 + +(㎡당) + +| 구분 | 단위 | 적용 | 비고 | +|---|---|---|---| +| 조경공 | 인 | 0.0084 | | +| 보통인부 | 인 | 0.0196 | | + +### 5-23-3. 떼꽂이 제작 및 설치 + +(㎡당) + +| 구분 | 단위 | 적용 | 비고 | +|---|---|---|---| +| 보통인부 | 인 | 0.0033 | 1일1인 1,000개,
㎡당 3.3개 기준 | diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md index 9db9e15e..aa1ccb64 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md @@ -5,10 +5,113 @@ PDF쪽: 81~83 인쇄쪽: 69~71 시작표지: 5-24. 씨앗뿜어붙이기 끝표지: 5-25. 거적덮기 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-24. 씨앗뿜어붙이기 + +### 5-24-1. 뿜어붙이기-기계/일반 + +(㎡당) + +| 구분 | | 규격 | 단위 | 적용수량 | 비고 | +|---|---|---|---|---|---| +| 자재 | 종자 | | kg | 0.025 | | +| | 비료 | 복합비료 | kg | 0.1 | | +| | 피복제 | 화이버 | kg | 0.18 | | +| | 침식안정제 | 합성접착제 | kg | 0.1 | | +| | 색소 | 색소 | kg | 0.002 | | +| 장비 | 종자살포기 | 2,500-3,000ℓ | 시간 | 0.0024 | | +| | 트럭 | 4.5ton | 시간 | 0.0024 | | +| | 물탱크 | 5,500ℓ | 시간 | 0.0036 | | +| 인력 | 조경공 | | 인 | 0.0007 | | +| | 보통인부 | | 인 | 0.0004 | | + + +[주] +① 씨앗의 배합은「도로비탈면 녹화공사의 설계및 시공 지침(2009)」 2.7 종자배합설계에 의한다. +② 외래종은 생태계에 미치는 영향을 고려하여 신중히 결정한다. +③ 배합 비율은 환경(비탈면 방향, 고도 등) 및 공사시기(계절) 등을 종합적으로 고려하여 판단한다. +④ 비탈면 구간은 살수차에 의한 살수가 불가능하나 살수차에 의한 관수의 인력품을 적용한다. +⑤ 「건설공사 표준품셈 공통부문, 4-1-2 초류종자 살포(2025)」참조한다. +⑥ 시드스프레이(seed spray) 공법이라 칭한다. +⑦ 물주기(인력) 필요시 보통인부 0.0005인 적용한다. 「건설공사 표준품셈 유지관리부문, 1-2-8 인력관수(2025)「」, 「건설공사 표준품셈 유지관리부문, 1-2-9 살수차관수(2025)」 참조한다. + + +### 5-24-2. 뿜어붙이기-기계/마사토 + +(㎡당) + +| 구분 | | 규격 | 단위 | 적용수량 | 비고 | +|---|---|---|---|---|---| +| 자재 | 종자 | | kg | 0.025 | | +| | 비료 | 복합비료 | kg | 0.1 | | +| | 피복제 | 화이버 | kg | 0.18 | | +| | 침식안정제 | 합성접착제 | kg | 0.1 | | +| | 색소 | 색소 | kg | 0.002 | | +| 장비 | 종자살포기 | 2,500-3,000ℓ | 시간 | 0.0024 | | +| | 트럭 | 4.5ton | 시간 | 0.0024 | | +| | 물탱크 | 5,500ℓ | 시간 | 0.0036 | | +| 인력 | 조경공 | 뿜어붙이기인력 | 인 | 0.0007 | | +| | 보통인부 | 뿜어붙이기인력 | 인 | 0.0004 | | + + +[주] +① 물주기(인력) 필요시 보통인부 0.0005인 적용한다. +② 골파기 : 1.0m간격/10㎡(3×3.33), A=0.3m/줄×2줄×3.33m=2.0㎡ +보통인부 0.02인×A=0.04인 + + +<자연경관복원형 적용지역 종자배합비율 조견표> + +| 복원목표 | 식생구분 | 종자배합비율(%) | +|---|---|---| +| 초본 위주형 | 관목류 | 20~40 | +| | 초본, 야생화류 | 40~80 | +| | 외래초종(양잔디류) | 0~10 | +| | 합계 | 100 | +| 초본ㆍ관목
혼합형 | 관목류 | 30~50 | +| | 초본, 야생화류 | 45~70 | +| | 외래초종(양잔디류) | 0~5 | +| | 합계 | 100 | +| 목본 군락형 | 교목류, 아교목류, 관목류 | 40~70 | +| | 초본, 야생화류 | 30~70 | +| | 외래초종(양잔디류) | 0 | +| | 합계 | 100 | + + +<기타 적용지역 종자배합비율 조견표> + +| 복원목표 | 식생구분 | 종자배합비율(%) | +|---|---|---| +| 초본 위주형 | 관목류 | 10∼40 | +| | 초본, 야생화류 | 40∼80 | +| | 외래초종(양잔디류) | 10∼20 | +| | 합계 | 100 | +| 초본·관목
혼합형 | 관목류, 아교목류 | 30∼50 | +| | 초본, 야생화류 | 40∼70 | +| | 외래초종(양잔디류) | 5∼15 | +| | 합계 | 100 | +| 목본 군락형 | 교목류, 아교목류, 관목류 | 35∼60 | +| | 초본, 야생화류 | 35∼65 | +| | 외래초종(양잔디류) | 3∼10 | +| | 합계 | 100 | + + + +[주] +① 「도로비탈면 녹화공사의 설계및 시공 지침 2.7 종자배합설계(2009)」 참조한다. +② 기타 적용지역이란 자연경관복원형의 복원목표가 적용되지 않는 지역을 말한다. +③ 해안생태계지역에서는 해안에 적합한 종자배합을 하고, 외래도입초종이 10%를 상회하지 않도록 한다. + +> 【적용 시 참고사항】 +> 가. 난지형초종인 weeping lovegrass 사용을 최대한 억제하며, 싸리와 낭아초는 20% 이하의 비율로 배합하여 지나치게 우점하지 않도록 한다. +> 나. 숫자는 중량배합비율을 의미하며 종자배합 시 외래초종의 중량배합비율을 우선 정한 다음 초본, 야생화류, 관목류 등 전체 종자 배합량의 합이 100이 되도록 정한다. +> 다. 복원목표가 자연경관 복원형인 경우 녹화식물은 그늘사초, 큰기름새, 대사초, 참억새, 새, 솔새, 개솔새, 양지꽃, 노루오줌, 구절초, 참취, 큰까치수영, 뚝갈 등의 초본류와 생강나무, 진달래, 철쭉, 개옻나무, 붉나무, 국수나무, 산초나무, 쥐똥나무, 개암나무, 호랑버들, 병꽃나무, 찔레나무, 산딸기, 복분자딸기, 노린재나무, 호랑버들, 소나무 등의 목본류가 비탈면 녹화용 자생종(재래초본, 재래목본)으로 사용될 수 있으며, 이들 외에도 부록에서 제시된 식물들을 추가로 사용할 수 있다. +> 라. 녹화용 식물은 현지에 서식하는 식물을 주로 활용하고, 중국산인 경우 산지를 확인하여 우리나라 기후 및 풍토와 유사한 지역인지를 확인한다. +> 마. 목본 군락형의 경우 교목류와 아교목류는 비탈면 상부의 토심이 깊고 경사도가 완만하고 비탈면 안정에 영향이 없으며 시거에 지장이 없는 구간에 적용이 가능하며, 성장 후 키가 낮은 식물을 우선 적용할 수 있다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md index 5c4f0696..2bc75bb6 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-25_거적덮기.md @@ -5,10 +5,25 @@ PDF쪽: 83 인쇄쪽: 71 시작표지: 5-25. 거적덮기 끝표지: 5-26. 흙갈이 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-25. 거적덮기 + +(㎡당) + +| 구분 | | 단위 | 수량 | 비고 | +|---|---|---|---|---| +| 자재 | 거적 | ㎡ | 1.1 | | +| 인력 | 조경공 | 인 | 0.002 | | +| | 보통인부 | 인 | 0.0007 | | + + +[주] +① 거적덮기에 필요한 앙카핀, 착지핀, 고정판, 고정끈 등 주자재에 부속된 자재는 별도 계상한다. +② 「건설공사 표준품셈 공통부문, 4-1-4 거적덮기(2025)」 참조한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md index 1c122293..a6ec94ff 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-26_흙갈이.md @@ -5,10 +5,55 @@ PDF쪽: 83~84 인쇄쪽: 71~72 시작표지: 5-26. 흙갈이 끝표지: 5-27. 식재면 관리 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-26. 흙갈이 + +### 5-26-1. 막갈이 + +(인/992㎡당) + +| 토성 | 막갈이깊이(cm) | | | | | +|---|---|---|---|---|---| +| | 9 | 12 | 15 | 18 | 21 | +| 사토 | 5 | 7 | 9 | 11 | 13 | +| 양토 | 6 | 8 | 11 | 13 | 15 | +| 식토 | 8 | 11 | 13 | 15 | 18 | + + +[주] +① 본 품은 답압된 토양의 구조적 개량을 위해 적용한다. +② 면고르기는 포함되어 있지 않다. + +### 5-26-2. 흙부수기 + +(인/992㎡당) + +| 토성 | 막갈이깊이(cm) | | | | | +|---|---|---|---|---|---| +| | 9 | 12 | 15 | 18 | 21 | +| 사토 | 3 | 4 | 5 | 6 | 7 | +| 양토 | 4 | 5 | 6 | 7 | 8 | +| 식토 | 5 | 6 | 7 | 8 | 9 | + + +[주] +본 품은 고르기를 포함한 것이다. + +### 5-26-3. 돌자갈치우기 + +(인/992㎡당) + +| 토성 | 경토깊이(cm) | | | +|---|---|---|---| +| | 10% 이내 | 10∼30% | 30% 이상 | +| 개답 | 2 | 6 | 17 | +| 개전 | 0.5 | 3.5 | 6.5 | + + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md index c67bdf04..109fabb1 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-27_식재면_관리.md @@ -5,10 +5,25 @@ PDF쪽: 84 인쇄쪽: 72 시작표지: 5-27. 식재면 관리 끝표지: 5-28. 비탈덮기 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-27. 식재면 관리 + +(인/10㎡당) + +| 구분 | 수량 | 비고 | +|---|---|---| +| 조경공 | 0.01 | | +| 보통인부 | 0.08 | | + +[주] +① 본 품은 흙 쌓기가 발생한 지역을 그 대상으로 한다. +② 본 품은 부토 및 면고르기가 완료된 상태에서 인력으로 잔돌제거 등 식재면을 정비하는 기준이다. +③ 본 품은 식재면고르기가 필요한 공종에 별도 계상한다. +④ 매토종자 이용 시 토양의 단면구조 보전 여부에 따라, 단면구조를 보전하지 않을 경우 토공을, 단면구조를 보전할 경우 다음의 표토관리 품과 운반공을 조합하여 반영한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md index d4c55cd6..375625ad 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-28_비탈덮기.md @@ -5,10 +5,42 @@ PDF쪽: 84~85 인쇄쪽: 72~73 시작표지: 5-28. 비탈덮기 끝표지: 5-29. 복사이식 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-28. 비탈덮기 + +### 5-28-1. 짚망 + +(인/100㎡당) + +| 구분 | 수량 | 비고 | +|---|---|---| +| 특별인부 | 0.19 | | +| 보통인부 | 0.06 | | + +[주] +① 본품은 성토 또는 절토사면에 짚, 망, 거적, 섶 등을 덮어 설치하는 기준이다. +② 재료량은 설계수량에 따라 별도 계상한다. + +### 5-28-2. 방초매트 및 야자섬유매트 포장 + +(일당) + +| 구분 | 단위 | 수량 | 시공량(㎡) | | +|---|---|---|---|---| +| | | | 폭 1.5m 이하 | 폭 2.0m 이하 | +| 조경공 | 인 | 2 | 90 | 130 | +| 보통인부 | 인 | 1 | | | + + +[주] +① 본 품은 설치 위치의 토공사가 완료된 상태에서 야자섬유 매트로 포장하는 기준이다 + +② 매트 포장면 정리, 야자섬유 매트 및 고정핀 설치, 매트 연결 및 고정, 마무리 작업을 포함한다. +③ 설치 위치의 토공 작업은 필요시 별도 계상한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md index 9be96618..bac6c58f 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-29_복사이식.md @@ -5,10 +5,32 @@ PDF쪽: 85 인쇄쪽: 73 시작표지: 5-29. 복사이식 끝표지: 5-30. 표시봉설치 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-29. 복사이식 + +| 구분 | 적용 | 비고 | +|---|---|---| +| K(버킷계수) | 0.9 | | +| f(체적환산계수) | 1/1.3 | | +| E(작업효율) | 0.70 | | +| Cm(1회 사이클시간) | 20(135°)sec | | + + +[주] +① 교목부터 초본 전체와 토양까지 단기간 내 이식하여 복원하는 기업이다. + +② 복사이식 들어내기 및 되메우기에 반영한다. +③ 무한궤도형 굴착기(1.0㎥) 적용한다. 0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 「건설공사 표준품셈 공통부문. 8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다. +④ Q1=3,600×q×K×f×E/Cm=㎥/시간 +⑤ Q=Q1*T=㎥/시간 +여기서 Q1 : 시간당 작업량(㎥/hr) +Q : 시간당 작업량(㎥/hr) +T : 표토두께(m) + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md index 3a615efd..bd6aac60 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-30_표시봉설치.md @@ -5,10 +5,23 @@ PDF쪽: 85 인쇄쪽: 73 시작표지: 5-30. 표시봉설치 끝표지: 5-31. 지주목설치 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-30. 표시봉설치 + +(1,000본당) + +| 구분 | 규격 | 소요인력 | 인력구분 | +|---|---|---|---| +| 표시봉 설치 | 길이 1.0m 이상 | 0.70 | 보통인부 | + +[주] +① 본 품은 표시봉 소운반과 설치비를 포함한다. +② 표시봉은 길이 1.0m 이상의 대나무 등 친환경 소재를 사용하며, 상부 쪽에 친환경성 흰색 수성페인트를 0.5m 이상 표시한다. +③ 표시봉의 가격은 시장가격을 조사하여 적용한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md index 79063b71..1cb9b9bc 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-31_지주목설치.md @@ -5,10 +5,23 @@ PDF쪽: 86 인쇄쪽: 74 시작표지: 5-31. 지주목설치 끝표지: 5-32. 대절작업 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-31. 지주목설치 + +(1,000본당) + +| 구분 | 규격 | 소요인력 | 인력구분 | +|---|---|---|---| +| 지주목 설치 | 길이 1.0m 이상 | 1.00 | 보통인부 | + +[주] +① 본 품은 지주목 소운반과 설치비를 포함한다. +② 지주목은 대묘식재만 적용하며, 묘목 1본당 지주목 1개를 적용한다. +③ 지주목은 길이 1.0m 이상의 목재로 0.3m 이상 땅속에 박히도록 설치하여야 한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md index 9c9f36e1..97e95cb2 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-32_대절작업.md @@ -5,10 +5,22 @@ PDF쪽: 86 인쇄쪽: 74 시작표지: 5-32. 대절작업 끝표지: 5-33. 묘목 가식작업 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-32. 대절작업 + +(1,000본당) + +| 구분 | 사용도구 | 소요인력 | 인력구분 | +|---|---|---|---| +| 대절 | 전정가위 | 0.50 | 특별인부 | + +[주] +① 초두부 고사방지를 위하여 식재 후 지상부 줄기에서 눈(芽)이 1~2개 남겨질 수 있도록 지상부에서 3~5㎝ 정도 위치를 전정가위로 잘라주는 것을 말한다. +② 백합나무, 오동나무 등 조림목을 대상으로 실시한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md index d05c01aa..a99a2051 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-33_묘목_가식작업.md @@ -5,10 +5,24 @@ PDF쪽: 86 인쇄쪽: 74 시작표지: 5-33. 묘목 가식작업 끝표지: 5-34. 묘목 소운반 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-33. 묘목 가식작업 + +(1,000본당) + +| 구분 | 소요인력 | 인력구분 | +|---|---|---| +| 소묘, 중묘 | 0.20 | 보통인부 | +| 대묘 | 0.30 | | + + +[주] +본 품은 가식장 준비, 차광망 설치와 물주기를 포함한다 + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md index 8825ab7e..a6a7f89e 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제05장_식재_및_녹화/5-34_묘목_소운반.md @@ -5,10 +5,69 @@ PDF쪽: 86~88 인쇄쪽: 74~76 시작표지: 5-34. 묘목 소운반 끝표지: 제6장 생육보조 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_4 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 5-34. 묘목 소운반 + +(1,000본당) + +| 구분 | 소요인력 | 인력구분 | +|---|---|---| +| 소묘 | 0.15 | 보통인부 | +| 중묘 | 0.20 | | +| 대묘 | 3.40 | | + + +[주] +① 묘목 소운반이란, 묘목을 가식장 또는 차량에서 식재지점까지 운반하는 것을 말한다. +② 대묘 중 용기묘 대묘의 경우 본 품의 50%를 계상한다. +③ 묘목의 대운반비는 2개 이상의 견적을 받아 적용하거나, 다음 자료를 참고하여 소운반비와 별도로 산출한다. + +<묘목의 규격별, 차량별 적재량 : ((사)한국양묘협회 제공자료)> + +| 규격 | 수종 및 묘령(형태) | 적재량 | | 비고 | +|---|---|---|---|---| +| | | 2.5톤 차량 | 5.0톤 차량 | | +| 소묘 | 소나무 1-1(노지묘) | 110곤포 | 200곤포 | 500본/곤포 | +| 중묘 | 편백 2-0(용기묘) | 150박스 | 300박스 | 100본/박스 | +| 대묘 | 해송 1-1-2(노지묘) | 1,800본 | 3,000본 | 분뜨기묘 1본 | +| | 소 2-2 (용기묘) | 300곤포 | 800곤포 | 10본/곤포 | +| ※ 묘목 1,000본당 무게
- 소나무 노지묘 1-1 (45㎏)
- 낙엽송, 편백 용기묘 2-0 (200㎏)
- 상수리 용기묘 1-0 (200㎏) | | | | | + + +> 【대운반비 단가산출 예시】 +> (1) 10ha에 식재할 소묘 30,000본을 100㎞ 운반할 경우의 대운반비 산출 +> ∙ 화물차별 묘목 적재량은 양묘협회에서 제공해 준 자료 활용. 차량 2.5톤에 적재할 수 있는 소묘 수량은 55,000본. +> ∙ 화물차 운송비는 거리별 구역화물 고시금액(2009년) 적용, 2.5톤 화물차 100㎞ 운송비는 91,810원. +> ∙ 소묘 1,000본당 대운반비를 산출하면 +> \- 차량 소요대수는 30,000본/55,000본 = 0.55대 +> \- 소묘 30,000본 운송비 : 91,810원 × 0.55대 = 50,495원. +> \- 소묘 1,000본당 대운반비 : 50,495원×(1,000본/30,000본) = 1,683원 +> (2) 20ha에 식재할 용기묘(편백2-0) 60,000본을 80㎞ 운반할 경우의 대운반비 산출 +> ∙ 차량 2.5톤에 적재할 수 있는 용기묘 수량은 15,000본. 5.0톤에 적재할 수 있는 용기묘 수량은 30,000본. +> ∙ 2.5톤 화물차 80㎞ 운송비는 84,620원. 5.0톤 화물차 80㎞ 운송비는 115,890원. +> ∙ 용기묘 운반차량은 5.0톤으로 결정 +> ∙ 용기묘 1,000본당 대운반비를 산출하면; +> \- 차량 소요대수는 60,000본/30,000본 = 2.0대(5톤 화물차) +> \- 용기묘 60,000본 운송비 : 115,890원 × 2대 = 231,780원. +> \- 용기묘 1,000본당 대운반비 : 231,780원×(1,000본/60,000본) = 3,863원 +> (3) 10ha에 식재할 대묘(해송 1-1-2 분뜨기묘) 15,000본을 80㎞ 운반할 경우의 대운반비 산출 +> ∙ 차량 2.5톤에 적재할 수 있는 대묘 수량은 1,800본. 5.0톤에 적재할 수 있는 대묘 수량은 3,000본. +> ∙ 2.5톤 화물차 80㎞ 운송비는 84,620원. 5.0톤 화물차 80㎞ 운송비는 115,890원. +> ∙ 대묘 운반차량은 5.0톤으로 할 경우, 대묘 1,000본당 대운반비를 산출하면 +> \- 차량 소요대수는 15,000본/3,000본 = 5.0대(5톤 화물차) +> \- 대묘 15,000본 운송비 : 115,890원 × 5대 = 579,450원. +> \- 대묘 1,000본당 대운반비 : 579,450원×(1,000본/15,000본) = 38,630원 +> ∙ 대묘 운반차량은 2.5톤으로 할 경우, 대묘 1,000본당 대운반비를 산출하면 +> \- 차량 소요대수는 15,000본/1,800본 = 8.33대(2.5톤 화물차) +> \- 대묘 15,000본 운송비 : 84,620원 × 8.33대 = 704,885원. +> \- 대묘 1,000본당 대운반비 : 704,885원×(1,000본/15,000본) = 46,992원 +> ※ 상기와 같은 대묘 운반은 5.0톤으로 적용함이 타당함. +> (4) 묘목본수는 본으로 하고 차량대수는 소수점 셋째자리에서 반올림하여 둘째자리까지 계산한다. +> (5) 운송비 및 대운반비 등 비용은 1원단위 미만 절사한다. diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md index a7f98cfc..b0a7a157 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/0-00_장머리.md @@ -5,10 +5,12 @@ PDF쪽: 97 인쇄쪽: 85 시작표지: 제7장 집재 및 운재 끝표지: 7-1. 인력집재 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_3 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +# 제7장 집재 및 운재 diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md index b940f37b..2444e698 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-01_인력집재.md @@ -5,10 +5,79 @@ PDF쪽: 97~98 인쇄쪽: 85~86 시작표지: 7-1. 인력집재 끝표지: 7-2. 수라집재 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_3 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 7-1. 인력집재 + +### 7-1-1. 수확 + +#### 1. 천연림 모두베기 + +| 구분 | 어려움
(15˚ 미만) | 중
(30˚ 초과) | 쉬움
(15˚~30˚) | +|---|---|---|---| +| | ㎥ | ㎥ | ㎥ | +| 100m이하 | 1.9 | 3.1 | 4.3 | +| 200m이하 | 1.4 | 2.3 | 3.3 | +| 300m이하 | 1.1 | 1.9 | 2.5 | + +[주] +ha당 원목재적 150㎥까지의 경우이며, 초과 시 “인공림 모두베기”를 적용한다. + +#### 2. 인공림 모두베기 + +| 경사 | 어려움(15˚ 미만) | | 중(30˚ 초과) | | 쉬움(15˚ ~30˚) | | +|---|---|---|---|---|---|---| +| ha당원목재적
평균거리 | 50㎥미만 | 50㎥이상 | 50㎥미만 | 50㎥이상 | 50㎥미만 | 50㎥이상 | +| | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | +| 100m이하 | 1.8 | 2.0 | 2.9 | 3.3 | 4.1 | 4.6 | +| 200m이하 | 1.3 | 1.5 | 2.1 | 2.4 | 3.0 | 3.4 | +| 300m이하 | 1.0 | 1.1 | 1.8 | 1.9 | 2.4 | 2.7 | + + +#### 3. 골라베기 + +| 경사 | 어려움(15˚미만) | | | 중(30˚초과) | | | 쉬움(15˚~30˚) | | | +|---|---|---|---|---|---|---|---|---|---| +| ha당원목재적
평균거리 | 15㎥
이하 | 15∼
30㎥ | 30㎥
초과 | 15㎥
미만 | 15∼
30㎥ | 30㎥
초과 | 15㎥
이하 | 15∼
30㎥ | 30㎥
초과 | +| | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | +| 100m이하 | 1.3 | 1.5 | 1.8 | 2.1 | 2.5 | 2.9 | 2.9 | 3.5 | 4.1 | +| 200m이하 | 1.0 | 1.1 | 1.8 | 1.5 | 1.8 | 2.1 | 2.1 | 2.5 | 2.9 | +| 300m이하 | 0.8 | 0.9 | 1.0 | 1.3 | 1.5 | 1.7 | 1.7 | 2.1 | 2.3 | + + + +[주] +경사도는 1개 기번 전임지의 평균경사도다. + +### 7-1-2. 숲가꾸기, 병해충방제 + +(㎥/1인, 1일) + +| 집재
거리 | 구분 | 간벌재의 직경(cm) | | | | | | | | 적용인부 | +|---|---|---|---|---|---|---|---|---|---|---| +| | | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22이상 | | +| 10m이하 | 숲가꾸기 | 2.02 | 2.84 | 3.51 | 4.08 | 4.82 | 5.81 | 6.95 | | 보통인부 | +| | 병해충방제 | 2.65 | 4.00 | 5.00 | 5.70 | 6.65 | 8.00 | 9.50 | 11.00 | | +| 20m이하 | 숲가꾸기 | 1.40 | 1.89 | 2.32 | 2.76 | 3.36 | 3.88 | 4.53 | | | +| | 병해충방제 | 1.75 | 2.50 | 3.10 | 3.65 | 4.45 | 5.00 | 5.70 | 6.35 | | +| 30m이하 | 숲가꾸기 | 1.10 | 1.49 | 1.84 | 2.27 | 2.71 | 3.06 | 3.51 | | | +| | 병해충방제 | 1.40 | 2.00 | 2.50 | 3.10 | 3.65 | 4.00 | 4.45 | 5.05 | | +| 40m이하 | 숲가꾸기 | 0.88 | 1.18 | 1.45 | 1.72 | 2.09 | 2.36 | 2.80 | | | +| | 병해충방제 | 1.15 | 1.60 | 2.00 | 2.35 | 2.85 | 3.10 | 3.65 | 4.25 | | +| 50m이하 | 숲가꾸기 | 0.74 | 0.99 | 1.22 | 1.47 | 1.76 | 1.94 | 2.30 | | | +| | 병해충방제 | 1.00 | 1.40 | 1.75 | 2.10 | 2.50 | 2.65 | 3.10 | 3.70 | | + + +[주] +① 본 품은 인력으로 원목(수간부)을 집재장까지 집재하는데 소요되는 품이며, 숲가꾸기는 간벌재 평균직경이 20㎝ 이하인 경우만 적용한다. +② 집재거리는 평균 집재거리(집재구간 최대거리의 1/2)를 적용한다. +③ 간벌재의 직경은 표준지조사 결과의 제거목 평균직경을 적용한다. +④ 산불예방 숲가꾸기 등 재해우려구역의 가지와 자투리나무 등 간벌목을 전량 수집할 경우는 제거목의 입목재적과 가지재적을 합산한 수량을 적용. 이 경우도 간벌재의 직경은 제거목 평균직경을 적용한다. +③ 할인․할증은 경사도(1-4-5), 장애물의 정도(1-4-8)를 반영한다. 다만 소나무재선충병방제 작업에만 장애물의 정도(1-4-8), 집재방향(1-4-14)을 반영한다. + diff --git a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md index 638014e8..0f9007c3 100644 --- a/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md +++ b/resources/knowledge/original/원가계산/산림_표준품셈/본문/제07장_집재_및_운재/7-02_수라집재.md @@ -5,10 +5,32 @@ PDF쪽: 98 인쇄쪽: 86 시작표지: 7-2. 수라집재 끝표지: 7-3. 아키야윈치(임업용 윈치) 집재 -상태: 대기 -작성: +상태: 검증대기 +작성: sub_laptop_3 검증: 원천: 글자층 -기계검사: +기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0 확정지문: --- + +## 7-2. 수라집재 + +(단위 : ㎥) + +| 집재거리 | ha당 집재재적 | | | | | 소요인력 | +|---|---|---|---|---|---|---| +| | 20이하㎥ | 21~30㎥ | 31~40㎥ | 41~50㎥ | 50초과㎥ | | +| 0~100m | 1.94 | 2.20 | 2.39 | 2.56 | 2.70 | 특별인부 | +| 0~150m | 1.64 | 1.91 | 2.12 | 2.29 | 2.45 | | +| 0~200m | 1.42 | 1.68 | 1.89 | 2.07 | 2.23 | | +| 0~250m | 1.24 | 1.50 | 1.70 | 1.88 | 2.04 | | + + +[주] +① 본 품은 설치․해체공정이 포함되었으며 산지경사가 20~65%일 경우 적용 가능하다. +② 산림병해충 및 소나무재선충병 방제 산물의 수집량은 34%를 가산하여 산출한다. + +> 【수집량 산출 예시】집재거리 200m이하, ha당 집재재적 21~30㎥인 경우 수집량 1.68㎥+(1.68㎥×34%)=2.25㎥ + +③ 할인․할증은 경사도(1-4-5), 횡단운반거리(1-4-15)를 반영한다 + diff --git a/resources/tester/helper_z01_fake_repo.py b/resources/tester/helper_z01_fake_repo.py deleted file mode 100644 index f20f5ddd..00000000 --- a/resources/tester/helper_z01_fake_repo.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Z01 기초단가 저장소 가짜 — DB 없이 API 시험(PLAN 1-2 · 2026-09-18). - -`install(monkeypatch)` 가 `Z01_MasterData_Repository_BasePrices` 의 함수 다섯을 바꿔 끼움. -주입 = 파일 원본 줄(`base_rows`) 그대로 · ⚠ '@' 칸(`@axis`·`@source`)은 **빼고** 담음 — -주입기가 안쪽 칸을 빼도 서버가 서는지 함께 잼. 판번호 규칙(다르면 409 · 쓰면 +1)은 진짜와 같게. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from Z01_MasterData import Z01_MasterData_BasePrices as base_prices -from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo -from Z01_MasterData import Z01_MasterData_WorkItems as work_items - - -class FakeRepo: - def __init__(self) -> None: - self.tables: dict[str, list[dict[str, Any]]] = {} - self.revisions: dict[str, int] = {} - self.saved_by: list[Any] = [] - - def table(self, kind: str) -> list[dict[str, Any]]: - if kind not in self.tables: - self.tables[kind] = [] - for row in base_prices.base_rows(kind): - data = {k: v for k, v in row.items() if not k.startswith("@")} - self.tables[kind].append( - {"row_key": row["@id"], "data": data, "seed": dict(data), "deleted": False} - ) - return self.tables[kind] - - def by_key(self, kind: str) -> dict[str, dict[str, Any]]: - return {r["row_key"]: r for r in self.table(kind)} - - async def load(self, kind: str) -> list[dict[str, Any]]: - return [ - {"row_key": r["row_key"], "data": dict(r["data"]), "seed": r["seed"]} - for r in self.table(kind) - if not r["deleted"] - ] - - async def revision(self, kind: str) -> int: - return self.revisions.get(kind, 0) - - async def counts(self) -> dict[str, int]: - return { - kind: sum(not r["deleted"] for r in self.table(kind)) - for kind in base_prices.KINDS - if kind not in work_items.KINDS - } - - async def save(self, kind, base_revision, changed, added, deleted, by) -> int: - current = self.revisions.get(kind, 0) - if current != base_revision: - raise repo.RevisionConflict(current) - rows = self.by_key(kind) - for key, data in changed.items(): - if not rows[key]["deleted"]: - rows[key]["data"] = dict(data) - for key, data in added.items(): - self.table(kind).append( - {"row_key": key, "data": dict(data), "seed": None, "deleted": False} - ) - for key in deleted: - rows[key]["deleted"] = True - self.revisions[kind] = current + 1 - self.saved_by.append(by) - return current + 1 - - async def reset(self, kind, row_keys, by) -> int: - rows = self.by_key(kind) - missing = sorted(set(row_keys) - set(rows)) - if missing: - raise repo.UnknownRows(missing) - for key in row_keys: - row = rows[key] - if row["seed"] is None: - row["deleted"] = True - else: - row["data"], row["deleted"] = dict(row["seed"]), False - self.revisions[kind] = self.revisions.get(kind, 0) + 1 - return self.revisions[kind] - - -def install(monkeypatch: pytest.MonkeyPatch) -> FakeRepo: - fake = FakeRepo() - for name in ("load", "revision", "counts", "save", "reset"): - monkeypatch.setattr(repo, name, getattr(fake, name)) - return fake diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index a67fae7f..67ec9b59 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -10,12 +10,10 @@ from __future__ import annotations -import asyncio import json from pathlib import Path import pytest -from helper_z01_fake_repo import install from Z01_MasterData import Z01_MasterData_BasePrices as base_prices @@ -30,13 +28,11 @@ def labels() -> dict: @pytest.fixture(scope="module") def shown() -> dict[str, list[dict]]: - """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다(DB 는 파일을 주입한 가짜).""" + """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다.""" out = {} - with pytest.MonkeyPatch.context() as patch: - install(patch) - for kind in base_prices.KINDS: - columns = base_prices._columns(kind, asyncio.run(base_prices.rows(kind))) - out[kind] = [base_prices.column_meta(kind, c) for c in columns] + for kind in base_prices.KINDS: + columns = base_prices._columns(kind, base_prices.rows(kind)) + out[kind] = [base_prices.column_meta(kind, c) for c in columns] return out @@ -103,8 +99,8 @@ def test_요율_축이_한_칸으로_안_합쳐졌다(shown): def test_요율_줄_열쇠에_축이_살아_있다(): - """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 주입 열쇠(row_key)가 흔들린다.""" - rows = base_prices.base_rows("rate") + """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다.""" + rows = base_prices.rows("rate") multi = [r for r in rows if "/" in str(r["@id"])] assert multi, "축이 든 줄 열쇠가 하나도 없다" assert any("=" in str(r["@id"]) for r in multi) @@ -113,7 +109,7 @@ def test_요율_줄_열쇠에_축이_살아_있다(): def test_기계_시간당_단가는_계산값으로_잠긴다(shown): """밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다).""" - meta = base_prices.table("machine", base_prices.base_rows("machine"), page=1, size=1) + meta = base_prices.table("machine", page=1, size=1) assert set(meta["formula"]) == { "hourly_loss_krw", "hourly_fuel_krw", @@ -159,15 +155,15 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown): def test_줄_열쇠가_서버_축과_같다(labels): - """⭐ 줄을 붙드는 끈 — 파일 원본이 줄마다 `@axis` 로 내는 축이 곧 열쇠(주입 row_key)다. + """⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다. - 이름표가 축을 빠뜨리면 새 판 주입 때 같은 줄을 못 알아본다. + 이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다. """ declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]} bad = [] for kind in base_prices.KINDS: axes = set() - for row in base_prices.base_rows(kind): + for row in base_prices.rows(kind): axes |= set(row.get("@axis") or ()) if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다 continue @@ -178,13 +174,13 @@ def test_줄_열쇠가_서버_축과_같다(labels): def test_구간_이름은_원문_표기_그대로다(): - """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 주입한 줄을 못 알아본다.""" + """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다.""" for kind in ("masonry_slope", "masonry_back_length"): - brackets = {str(r.get("height_bracket")) for r in base_prices.base_rows(kind)} + brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)} assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets)) classes = { str(r.get("class")) - for r in base_prices.base_rows("masonry_class") + for r in base_prices.rows("masonry_class") if r.get("table") == "boulder_diameter" } assert classes == {"40~60", "60~80", "80~100"}, sorted(classes) @@ -202,13 +198,13 @@ def test_축_칸은_못_고친다(): """축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다.""" bad = [] for kind in base_prices.KINDS: - rows = base_prices.base_rows(kind) + rows = base_prices.rows(kind) axes = set() for row in rows: axes |= set(row.get("@axis") or ()) if not axes: continue - meta = base_prices.table(kind, rows, page=1, size=1) + meta = base_prices.table(kind, page=1, size=1) for axis in axes: if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") @@ -232,11 +228,10 @@ def test_기초단가_상자_이름이_이름표에_있다(labels): assert named["pumsem_basis"]["name_ko"] == "품셈 기준" -def test_상자마다_kind_가_있다(monkeypatch): +def test_상자마다_kind_가_있다(): """빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다.""" - install(monkeypatch) counted: dict[str, int] = {} - for entry in asyncio.run(base_prices.kinds()): + for entry in base_prices.kinds(): counted[entry["group"]] = counted.get(entry["group"], 0) + 1 assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다" assert entry["rows"] > 0, entry["kind"] diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 1af960b9..911b90fc 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -1,27 +1,23 @@ -"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2). +"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시). -틀: 정본 = DB `master_base_price`(data = 지금 값 · seed = 초기값) · 원본 파일 `resources/data_*` 는 **주입 재료로 읽기만** · -[저장] 한 번 = {base_revision, edits, added, deleted} → 판이 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · -줄마다 changed_columns(초기값과 다른 칸)·is_added(관리자 추가 줄) · [초기값으로] = data := seed(추가 줄은 지움). +틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개 +`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 · +덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) · 기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜. -DB 는 `helper_z01_fake_repo` 가짜로 — 저장소 SQL 흐름(잠금·되돌림)은 맨 아래 가짜 커넥션으로 잼. """ from __future__ import annotations -import asyncio import hashlib import json from decimal import Decimal from pathlib import Path -from types import SimpleNamespace import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from helper_z01_fake_repo import FakeRepo, install -from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo +from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Router as router_module ROOT = Path(__file__).resolve().parents[2] @@ -40,12 +36,14 @@ SOURCES = [ @pytest.fixture -def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo: - return install(monkeypatch) +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + folder = tmp_path / "data_master_override" + monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder) + return folder @pytest.fixture -def client(fake: FakeRepo) -> TestClient: +def client(store: Path) -> TestClient: from common_util.common_util_auth import verify_session app = FastAPI() @@ -65,20 +63,8 @@ def _row(client: TestClient, kind: str, row_id: str) -> dict: return next(r for r in rows if r["@id"] == row_id) -def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None): - if base is None: - base = _get(client, kind, size=1)["revision"] - body = { - "base_revision": base, - "edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits], - "added": [{"data": d} for d in added], - "deleted": list(deleted), - } - return client.put(f"/api/master-data/base-prices/{kind}", json=body) - - -def _edit(client: TestClient, kind: str, row_id: str, column: str, value): - return _save(client, kind, edits=[(row_id, column, value)]) +def _put(client: TestClient, kind: str, row_id: str, values: dict): + return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values}) def _sha(path: Path) -> str: @@ -95,15 +81,13 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No for kind, (total, editable) in expected.items(): table = _get(client, kind, size=500) assert table["total"] == total and table["editable"] == editable, kind - assert table["status"] == "success" and table["revision"] == 0, kind ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]] assert len(ids) == len(set(ids)), kind - # 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감 - assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"]) - assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")} + assert all("@overrides" not in r for r in table["rows"]), ( + kind + ) # 안 고친 줄엔 칸 자체가 없음 columns = {c["key"] for c in table["columns"]} assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖 - assert not columns & {"changed_columns", "is_added"}, kind assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" @@ -132,8 +116,8 @@ def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를 "hourly_total_krw" in row["@formula"] and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"] ) - res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1) - assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"] + res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1}) + assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: @@ -162,23 +146,38 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien assert checked >= 5 -def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None: +def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None: before = {p: _sha(p) for p in SOURCES} - res = _save( - client, - "labor", - edits=[("labor_const/1001", "daily_wage_krw", 230000)], - base=0, - ) + res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) assert res.status_code == 200, res.text - assert res.json() == {"status": "success", "revision": 1, "added": []} - row = _row(client, "labor", "labor_const/1001") - assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"] - stored = fake.by_key("labor")["labor_const/1001"] - assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907 - assert fake.saved_by == [42] # 누가 고쳤나 - assert _get(client, "labor", size=1)["revision"] == 1 - assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다 + row = res.json() + assert row["daily_wage_krw"] == 230000 + assert row["@overrides"] == { + "daily_wage_krw": { + "original": 215907, + "value": 230000, + "state": overrides.EDITED, + "current": 215907, + } + } + assert ( + _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000 + ) + assert ( + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code + == 200 + ) + assert sorted(p.name for p in store.iterdir()) == [ + "labor.json", + "oil.json", + ] # kind 마다 한 파일 + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"] + assert ( + entry["original"] == 215907 + and entry["source"] == "labor_const_2026-01-01.json" + and entry["by"] == 42 + ) assert {p: _sha(p) for p in SOURCES} == before @@ -186,113 +185,139 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien base = _row(client, "machine", "0101-0007") operator = base["operator_occupation_code"] wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] - assert _edit( - client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000 - ).is_success - assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success - price = base["price_thousand_krw"] - assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success + _put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000}) + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000}) + _put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2}) after = _row(client, "machine", "0101-0007") assert after["hourly_operator_krw"] > base["hourly_operator_krw"] assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"] assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2) - # 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜 - assert after["changed_columns"] == ["price_thousand_krw"] + price = base["price_thousand_krw"] + assert after["@overrides"] == { + "price_thousand_krw": { + "original": price, + "value": price * 2, + "state": overrides.EDITED, + "current": price, + } + } # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜 -def test_계산_칸은_DB_에_안_씀(client: TestClient, fake: FakeRepo) -> None: - """기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴.""" - _edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001) - data = fake.by_key("machine")["0101-0007"]["data"] - assert data["loss_coefficient_per_hour"] == 0.001 - assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data) +def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None: + _put(client, "material", "10023392", {"price_krw": 30}) + reverted = _put(client, "material", "10023392", {"price_krw": None}).json() + assert reverted["price_krw"] == 22 and "@overrides" not in reverted + assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200 + saved = json.loads((store / "material.json").read_text(encoding="utf-8")) + assert saved["overrides"] == {} -def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None: - good = ("labor_const/1001", "daily_wage_krw", 230000) - for bad, status in ( - (("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸 - (("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값 - (("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄 - ): - res = _save(client, "labor", edits=[good, bad]) - assert res.status_code == status, (bad, res.text) - assert res.json()["status"] == "error" - res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"]) - assert res.status_code == 404 - assert fake.revisions == {} and fake.saved_by == [] - assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 - - -def test_낡은_판으로_저장하면_409( - client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch +def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김( + client: TestClient, store: Path ) -> None: - assert _save( - client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0 - ).is_success - stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) - assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"] - assert _row(client, "labor", "labor_const/1002")["changed_columns"] == [] - # 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄) - - async def old_revision(kind: str) -> int: - return 0 - - monkeypatch.setattr(repo, "revision", old_revision) - raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) - assert raced.status_code == 409 and fake.revisions["labor"] == 1 - - -def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None: - data = {"specification": "ZZ시험 규격, 특수", "unit": "개", "price_krw": 1234} - res = _save(client, "material", added=[data]) - assert res.status_code == 200, res.text - (key,) = res.json()["added"] - assert key.startswith("added/") and res.json()["revision"] == 1 - table = _get(client, "material", size=1, page=7000) - assert table["total"] == 7000 - row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례) - assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, []) - assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수" - assert _get(client, "material", q="ZZ시험")["total"] == 1 - # 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침 - assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success - assert _edit(client, "material", "10023392", "specification", "x").status_code == 400 - for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}): - assert _save(client, "material", added=[bad]).status_code == 400, bad - assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄 - gone = _save(client, "material", deleted=[key, "10023392"]) - assert gone.status_code == 200 - ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]} - assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998 - assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게) - - -def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None: - _edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000) - added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}]) - (key,) = added.json()["added"] - _save(client, "labor", deleted=["labor_const/1002"]) - res = client.post( - "/api/master-data/base-prices/labor/reset", - json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]}, + store.mkdir() + (store / "labor.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "kind": "labor", + "overrides": { + "labor_const/1001": { + "daily_wage_krw": { + "value": 199000, + "original": 200000, + "source": "labor_const_2025-07-01.json", + } + }, + "labor_const/9999": { + "daily_wage_krw": { + "value": 1, + "original": 1, + "source": "labor_const_2025-07-01.json", + } + }, + }, + } + ), + encoding="utf-8", ) - assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4} - back = _row(client, "labor", "labor_const/1001") - assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == [] - assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림 - assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움 - missing = client.post( - "/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]} + # 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이 + row = _row(client, "labor", "labor_const/1001") + assert row["daily_wage_krw"] == 199000 + mark = row["@overrides"]["daily_wage_krw"] + assert mark == { + "original": 200000, + "value": 199000, + "state": overrides.SOURCE_CHANGED, + "current": 215907, + } + _put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000}) + listed = client.get("/api/master-data/overrides").json() + assert [ + (i["row_id"], i["state"]) for i in listed["items"] + ] == [ # 서버가 정렬 — 원본 바뀜이 맨 위 + ("labor_const/1001", overrides.SOURCE_CHANGED), + ("labor_const/9999", overrides.ORPHAN), + ("labor_const/1002", overrides.EDITED), + ] + first = listed["items"][0] + assert set(first) == { + "kind", + "row_id", + "row_label", + "column", + "column_label", + "original", + "current", + "value", + "state", + "unit", + } + assert first["row_label"] == "작업반장" and ( + first["original"], + first["current"], + first["value"], + ) == (200000, 215907, 199000) + assert listed["items"][1]["current"] is None + saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움 + # 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기 + kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json() + assert kept["@overrides"]["daily_wage_krw"] == { + "original": 215907, + "value": 199000, + "state": overrides.EDITED, + "current": 215907, + } + taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json() + assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken + + +def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜( + client: TestClient, store: Path +) -> None: + blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None) + row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json() + assert row["@overrides"]["daily_wage_krw"] == { + "original": None, + "value": 150000, + "state": overrides.EDITED, + "current": None, + } + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = ( + 140000 # 옛 판엔 값이 있었다고 치면 + ) + (store / "labor.json").write_text(json.dumps(saved), encoding="utf-8") + assert ( + _row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"] + == overrides.SOURCE_CHANGED ) - assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"] - empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []}) - assert empty.status_code == 422 # 통째로 되돌리기 막음 def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: rate = _get(client, "rate", size=1) - assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"]) + assert any("갱신" in line and "주인" in line for line in rate["notice"]) for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로 notice = _get(client, kind, size=1)["notice"] assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind @@ -300,18 +325,16 @@ def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: def test_막는_자리(client: TestClient) -> None: - assert _save(client, "wage", base=0).status_code == 404 - assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫 - locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸 - assert locked.status_code == 400 and "공표 원문" in locked.json()["message"] + assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400 + locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸 + assert locked.status_code == 400 and "공표 원문" in locked.text for bad in (True, -1, "220000", float("nan")): - body = { - "base_revision": 0, - "edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}], - } + body = json.dumps({"values": {"daily_wage_krw": bad}}).encode() res = client.put( - "/api/master-data/base-prices/labor", - content=json.dumps(body).encode(), + "/api/master-data/base-prices/labor/labor_const/1001", + content=body, headers={"content-type": "application/json"}, ) assert res.status_code in (400, 422), bad @@ -321,20 +344,9 @@ def test_막는_자리(client: TestClient) -> None: if r["@id"].startswith("rate_performance_guarantee_fee/brackets/") ) assert ( - _edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400 - ) # 그 줄엔 요율 칸 없음 + _put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400 + ) # 그 줄엔 요율 칸이 없음 assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56") - many = [("labor_const/1001", "daily_wage_krw", 1)] * 501 - assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지 - extra = client.put( - "/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}} - ) - assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함) - old = client.put( - "/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}} - ) - assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐 - assert client.get("/api/master-data/overrides").status_code == 404 def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None: @@ -352,6 +364,61 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None +def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None: + """브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음.""" + store.mkdir() + gone = { + f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}} + for i in range(3) + } + (store / "material.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}), + encoding="utf-8", + ) + _put(client, "material", "10023392", {"price_krw": 30}) + _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + listed = client.get( + "/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"} + ).json() + assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN} + assert listed["counts"] == { + "material": {overrides.ORPHAN: 3, overrides.EDITED: 1}, + "labor": {overrides.EDITED: 1}, + } + paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json() + assert paged["total"] == 5 and len(paged["items"]) == 2 + everything = client.get("/api/master-data/overrides").json()[ + "items" + ] # labor 가 kind 차례로 앞이어도 + assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2 + res = client.post( + "/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN} + ) + assert res.status_code == 200 and res.json()["removed"] == 3 + left = client.get("/api/master-data/overrides").json() + assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례 + ("labor", overrides.EDITED), + ("material", overrides.EDITED), + ] + assert ( + client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code + == 422 + ) # 거름 없이 통째로 못 비움 + + +def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None: + rates = _get(client, "rate", size=500)["rows"] + fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")) + store.mkdir() + entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}} + (store / "rate.json").write_text( + json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8" + ) + items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"] + assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)] + assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음 + + SOURCE_KEYS = { "source_id", "name", @@ -409,6 +476,12 @@ def test_우리_판은_출처표_글자가_아니라_파일에서_읽음( assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로) +def test_모아_보기_항목에_단위(client: TestClient) -> None: + _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + (item,) = client.get("/api/master-data/overrides").json()["items"] + assert item["unit"] == "원" and item["column_label"] == "일 노임" + + def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: """브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본.""" labels = json.loads( @@ -467,8 +540,8 @@ def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None: assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐 -def test_고친_값으로_세움(client: TestClient) -> None: - _edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000) +def test_덮개_얹은_값으로_세움(client: TestClient) -> None: + _put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000}) top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0] assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000 @@ -478,107 +551,54 @@ def test_sort_가_없으면_원본_차례(client: TestClient) -> None: assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" -def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None: - """카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄.""" - data = { - "machine_name": "시험 굴삭기", - "price_thousand_krw": 100000, - "loss_coefficient_per_hour": 0.0001, - } - (key,) = _save(client, "machine", added=[data]).json()["added"] - row = _get(client, "machine", size=1, page=614)["rows"][0] - assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000) - assert row["hourly_total_krw"] is None and row["hourly_note"] - assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸 +def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None: + """브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격). + + 값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌. + """ + saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json() + assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED + entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"] + assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만 + assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음 + assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨 + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}) + oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"] + assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"] + entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름" + (store / "labor.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8" + ) + mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"] + assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜 + # 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌) + assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published" + assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published" + listed = client.get("/api/master-data/overrides").json() + top = listed["items"][0] + assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게 + assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘 + assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED + assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED) -# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ────────────────────────────── - - -class _Cursor: - def __init__(self, db: SimpleNamespace) -> None: - self.db = db - - async def __aenter__(self): - return self - - async def __aexit__(self, *_): - return False - - async def execute(self, sql: str, params=()) -> None: - self.db.sql.append(" ".join(sql.split())) - - async def fetchone(self): - return (self.db.revision,) - - async def fetchall(self): - return [(key,) for key in self.db.keys] - - -class _Connection: - def __init__(self, db: SimpleNamespace) -> None: - self.db = db - - def cursor(self, *_): - return _Cursor(self.db) - - async def begin(self) -> None: - self.db.log.append("begin") - - async def commit(self) -> None: - self.db.log.append("commit") - - async def rollback(self) -> None: - self.db.log.append("rollback") - - -class _Pool: - def __init__(self, db: SimpleNamespace) -> None: - self.db = db - - def acquire(self): - return self - - async def __aenter__(self): - return _Connection(self.db) - - async def __aexit__(self, *_): - return False - - -def _writes(db: SimpleNamespace) -> list[str]: - return [ - s - for s in db.sql - if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price")) - ] - - -def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None: - db = SimpleNamespace(sql=[], log=[], revision=4, keys=[]) - monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) - with pytest.raises(repo.RevisionConflict): - asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7)) - assert db.log == ["begin", "rollback"] and not _writes(db) - db.sql.clear() - db.log.clear() - assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5 - assert db.log == ["begin", "commit"] - lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE")) - bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision")) - writes = [db.sql.index(s) for s in _writes(db)] - assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1 - assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만 - - -def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None: - db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"]) - monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) - with pytest.raises(repo.UnknownRows): - asyncio.run(repo.reset("labor", ["a", "없음"], 7)) - assert db.log == ["begin", "rollback"] and not _writes(db) - db.sql.clear() - db.log.clear() - assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3 - assert db.log == ["begin", "commit"] - assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "] +def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None: + _put(client, "machine", "0101-0007", {"price_thousand_krw": 80000}) + meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][ + "0101-0007" + ]["price_thousand_krw"]["meaning"] + assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7" + assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")] + store.mkdir( + exist_ok=True + ) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음) + (store / "material.json").write_text( + json.dumps( + {"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}} + ), + encoding="utf-8", + ) + assert ( + _row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED + ) diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index e0bf710b..88b17725 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(monkeypatch: pytest.MonkeyPatch) -> TestClient: +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: from common_util.common_util_auth import verify_session - install(monkeypatch) # DB 가짜 — 파일 원본을 초기값으로 주입한 모양 + monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} @@ -69,13 +69,13 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None table = _get(client, kind, size=500) assert table["total"] == len(table["rows"]) > 0, kind ids = _ids(table) - assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 (kind, row_key) 한 줄이 둘을 덮음 + assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음 assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글 assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())} assert {table["locked"][c] for c in axis if c in table["locked"]} == { - "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음" + "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" }, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함 assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로) @@ -165,39 +165,35 @@ def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None: ) -def _save(client: TestClient, kind: str, row_key: str, column: str, value): - revision = _get(client, kind, size=1)["revision"] - return client.put( - f"/api/master-data/base-prices/{kind}", - json={ - "base_revision": revision, - "edits": [{"row_key": row_key, "column": column, "value": value}], - }, +def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None: + res = client.put( + "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", + json={"values": {"m3_per_m2": 0.1}}, ) - - -def test_고치기도_같은_길(client: TestClient) -> None: - res = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "m3_per_m2", 0.1) assert res.status_code == 200, res.text - rows = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]} - row = rows["wedge_stone_m3_per_m2/깬잡석/25"] - assert row["m3_per_m2"] == 0.1 and row["changed_columns"] == ["m3_per_m2"] - assert [k for k, r in rows.items() if r["changed_columns"]] == [ - "wedge_stone_m3_per_m2/깬잡석/25" - ] - blocked = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "stone_kind", "딴 돌") + row = res.json() + assert row["m3_per_m2"] == 0.1 + assert row["@overrides"]["m3_per_m2"]["original"] == 0.09 + assert (tmp_path / "data_master_override" / "stone_kind.json").is_file() + listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json() + assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"] + blocked = client.put( + "/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25", + json={"values": {"stone_kind": "딴 돌"}}, + ) assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침 - assert not {"@axis", "@source", "@data"} & set(row) # 안쪽 칸은 안 내보냄 + assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄 # 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함 - # ⚠ 주입 data 에 `@axis` 가 없어도 막힘(축은 파일 원본에서 읽음 · 가짜 주입은 '@' 칸을 뺌) - axis_edit = _save( - client, "formwork_reuse", "reuse_ratio_pct/plywood_material_pct/2", "reuse_count", 3 + axis_edit = client.put( + "/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood_material_pct/2", + json={"values": {"reuse_count": 3}}, ) assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text - value_edit = _save(client, "formwork_reuse", "reuse_by_class/복잡한 구조", "reuse_count", 3) - assert value_edit.status_code == 200 - reuse = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]} - assert reuse["reuse_by_class/복잡한 구조"]["reuse_count"] == 3 + value_edit = client.put( + "/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조", + json={"values": {"reuse_count": 3}}, + ) + assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3 def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None: diff --git a/resources/tester/test_z01_master_draft.py b/resources/tester/test_z01_master_draft.py deleted file mode 100644 index 30135a5f..00000000 --- a/resources/tester/test_z01_master_draft.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18). - - ① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄 - ② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움 - ③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기) - ④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음 - ⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order) - ⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422 -TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식). -""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" -SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts" - -_RUNNER = """ -import { writeFileSync } from "node:fs"; - -// 브라우저 sessionStorage 대역 -const store = new Map(); -globalThis.sessionStorage = { - getItem: (k) => (store.has(k) ? store.get(k) : null), - setItem: (k, v) => store.set(k, String(v)), - removeItem: (k) => store.delete(k), -}; -const { - BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload, -} = await import("./Z01_MasterData_UI_Draft.js"); - -// ① 기초단가 -const base = new BaseDraft("base:labor", 7); -base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907); -base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임 -base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2); -base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐 -base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4); -base.toggleDelete("labor_const/9999"); -const added = base.addRow(); -base.setAdded(added.tmp, "occupation_name", "새 직종"); -base.setAdded(added.tmp, "daily_wage_krw", 300000); -base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌 -const payload = base.payload(); -const count = base.count; -const stored = JSON.parse(store.get("z01-draft:base:labor")); - -// ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움 -const again = new BaseDraft("base:labor", 7); -const restored = again.payload(); -const newer = new BaseDraft("base:labor", 8); -const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") }; -const toggled = new BaseDraft("base:oil", 1); -toggled.toggleDelete("x"); -toggled.toggleDelete("x"); -const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") }; - -// ③④⑤ 공종 -const row = (id, variant, extra = {}) => ({ - id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부", - resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null, - group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1", - // GET 전용 칸 — PUT 에 실리면 422(extra=forbid) - sort_order: id, is_added: false, changed_columns: [], ...extra, -}); -const detail = { - item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목", - path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1, - basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 }, - rows: [row(1, "갑"), row(2, "을"), row(3, "갑")], - source: [], notes: [], -}; -const body = freshBody(detail); -const clean = isDirty(detail, body); -body.rows[0].amount = 0.7; -body.rows[1].deleted = true; -addRow(body, "을", "new-1"); -body.item.status = "reviewed"; -const sent = workItemPayload(detail.item.revision, body); - -writeFileSync(process.argv[2], JSON.stringify({ - payload, count, stored, - restored, dropped, emptied, - clean, - dirty: isDirty(detail, body), - sent, - changed: [...changedFields(body.rows[0], detail.rows)], - addedRow: changedFields(body.rows[2], detail.rows), - untouched: [...changedFields(body.rows[3], detail.rows)], - groups: groupByVariant(body.rows), - freshUntouched: freshBody(detail).rows[0].amount, -})); -""" - - -def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None: - out = tmp_path / "js" - subprocess.run( # noqa: S603 — 고정 실행 파일 - [ - "node", - str(TSC), - str(SOURCE), - "--outDir", - str(out), - "--module", - "esnext", - "--target", - "es2022", - "--ignoreConfig", - "--noCheck", - "--noResolve", - ], - cwd=str(PROJECT_ROOT), - check=True, - capture_output=True, - ) - (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") - result = tmp_path / "output.json" - subprocess.run( # noqa: S603 - ["node", str(out / "runner.mjs"), str(result)], - cwd=str(PROJECT_ROOT), - check=True, - capture_output=True, - ) - got = json.loads(result.read_text(encoding="utf-8")) - - # ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만 - assert got["payload"] == { - "base_revision": 7, - "edits": [ - {"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000}, - ], - "added": [{"data": {"occupation_name": "새 직종"}}], - "deleted": ["labor_const/9999"], - } - # 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1 - assert got["count"] == 4 - assert got["stored"]["revision"] == 7 - # ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐 - assert got["restored"] == got["payload"] - assert got["dropped"] == {"count": 0, "left": False} - assert got["emptied"] == {"count": 0, "left": False} - # ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림 - assert got["clean"] is False - assert got["dirty"] is True - sent = got["sent"] - assert sent["base_revision"] == 3 - assert sent["item"] == { - "name": "벌목", - "basis_quantity": 1, - "basis_unit": "ha", - "status": "reviewed", - } - assert [r.get("id") for r in sent["rows"]] == [1, None, 3] - # ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자 - fields = { - "variant", - "resource_kind", - "resource_key", - "resource_name", - "resource_spec", - "amount", - "amount_unit", - "alternative_amount", - "group_ratio_pct", - "raw_cell", - "pum_table_id", - } - assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}] - assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == "" - # ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림 - assert got["changed"] == ["amount"] - assert got["addedRow"] == "added" - assert got["untouched"] == [] - assert got["freshUntouched"] == 0.5 - # ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로 - assert got["groups"] == [ - {"variant": "갑", "indexes": [0, 3]}, - {"variant": "을", "indexes": [1, 2]}, - ] diff --git a/resources/tester/test_z01_work_item_edit.py b/resources/tester/test_z01_work_item_edit.py deleted file mode 100644 index 040b3475..00000000 --- a/resources/tester/test_z01_work_item_edit.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Z01 공종 편집 — 규칙(순수 함수) · 요청 모델 · 저장 흐름(409·422·전부 아니면 전무) · 등록. - -(2026-09-18 브레인 PLAN 1-0 계약 · sub_laptop_3) DB 없이 — 저장 흐름은 가짜 커넥션·저장소로 훑음. -""" - -from __future__ import annotations - -import importlib -from contextlib import asynccontextmanager -from decimal import Decimal - -import pytest -from fastapi import FastAPI -from fastapi.routing import APIRoute -from fastapi.testclient import TestClient -from pydantic import ValidationError - -from common_util.common_util_auth import require_system_admin, verify_session -from Z01_MasterData import Z01_MasterData_Router_WorkItems as router_module -from Z01_MasterData import Z01_MasterData_WorkItems as work_items - -SEED_ROW = {"resource_kind": "labor", "resource_key": "L-001", "amount": 1.5, "sort_order": 0} - - -def _row(row_id, seed=SEED_ROW, **values): - base = {"id": row_id, "variant": "", "resource_kind": "labor", "resource_key": "L-001"} - return {**base, "amount": Decimal("1.500000"), "sort_order": 0, "seed": seed, **values} - - -# ── 규칙 ── - - -def test_고친_칸은_초기값과_값으로_비교함() -> None: - assert work_items.changed_columns(_row(1), SEED_ROW, work_items.ROW_FIELDS) == [] - changed = _row(1, amount=Decimal("2")) - assert work_items.changed_columns(changed, SEED_ROW, work_items.ROW_FIELDS) == ["amount"] - assert work_items.changed_columns(_row(1, seed=None), None, work_items.ROW_FIELDS) == [] - - -def test_검사는_남의_줄_겹친_줄_없는_자원을_막음() -> None: - rows = [ - {"id": 1, "resource_key": "L-001"}, - {"id": 1, "resource_key": None}, - {"id": 99, "resource_key": None}, - {"id": None, "resource_key": "없는열쇠"}, - ] - errors = work_items.check_rows(rows, existing_ids={1, 2}, known_keys={"L-001"}) - assert len(errors) == 3 - assert "두 번" in errors[0] and "id 99" in errors[1] and "없는열쇠" in errors[2] - assert work_items.check_rows(rows[:1], {1}, {"L-001"}) == [] - - -def test_전체_줄_보내기는_바뀐_줄만_쓰고_빠진_줄은_숨기거나_지움() -> None: - existing = [_row(1), _row(2, sort_order=1), _row(3, seed=None, sort_order=2)] - same = {f: existing[0].get(f) for f in work_items.ROW_FIELDS} | {"id": 1, "amount": 1.5} - new = {"id": None, "resource_kind": "material", "resource_key": None, "amount": None} - plan = work_items.plan_rows(existing, [same, new]) - assert plan["updates"] == [] # 1.500000 = 1.5 · 순서도 그대로 - assert plan["inserts"][0]["resource_kind"] == "material" - assert plan["inserts"][0]["sort_order"] == 1 - assert plan["hide"] == [2] # 초기값 줄 — 숨김(되살릴 수 있게) - assert plan["drop"] == [3] # 관리자 추가 줄 — 지움 - - moved = work_items.plan_rows(existing[:2], [{**same, "id": 2}, same]) - assert [u["id"] for u in moved["updates"]] == [2, 1] # 순서만 바뀌어도 sort_order 를 씀 - - -def test_초기값으로는_seed_칸만_살리고_추가_줄은_지움() -> None: - head = {"id": 7, "name": "고친 이름", "seed": {"name": "원 이름", "status": "partial"}} - rows = [_row(1, amount=Decimal("9")), _row(2, is_deleted=1), _row(3, seed=None)] - plan = work_items.reset_plan(head, rows) - assert plan["head"] == {"name": "원 이름", "status": "partial"} - assert [u["id"] for u in plan["updates"]] == [1, 2] - assert plan["updates"][0]["amount"] == 1.5 - assert plan["drop"] == [3] and plan["inserts"] == [] and plan["hide"] == [] - - -def test_상세는_seed_를_안_내보내고_고친_칸만_알림() -> None: - head = {"id": 7, "work_item_key": "FW-00001", "name": "새", "status": "partial"} - head |= {"seed": {"name": "옛"}, "source": [{"pum_table_id": "T1"}], "notes": ["[주] 1"]} - out = work_items.detail(head, [_row(1), _row(2, seed=None)], revision=4) - assert out["item"]["revision"] == 4 and out["item"]["changed_columns"] == ["name"] - assert "seed" not in out["item"] and "id" not in out["item"] - assert out["source"] == [{"pum_table_id": "T1"}] and out["notes"] == ["[주] 1"] - assert [r["is_added"] for r in out["rows"]] == [False, True] - assert out["rows"][0]["id"] == 1 and "seed" not in out["rows"][0] - - -# ── 요청 모델 ── - - -def _body(**row): - item = {"name": "공종", "status": "reviewed"} - return {"base_revision": 3, "item": item, "rows": [{"resource_kind": "labor", **row}]} - - -@pytest.mark.parametrize( - "row", - [{"amount": -1}, {"resource_key": ""}, {"resource_kind": "oil"}, {"unknown": 1}], -) -def test_요청_모델은_음수_빈열쇠_모르는_종류_모르는_칸을_막음(row) -> None: - with pytest.raises(ValidationError): - router_module.WorkItemSave.model_validate(_body(**row)) - - -# ── 저장 흐름(가짜 커넥션) ── - - -class _Connection: - def __init__(self) -> None: - self.log: list[str] = [] - - async def begin(self) -> None: - self.log.append("begin") - - async def commit(self) -> None: - self.log.append("commit") - - async def rollback(self) -> None: - self.log.append("rollback") - - -@pytest.fixture -def client(monkeypatch): - connection = _Connection() - - class _Pool: - @asynccontextmanager - async def acquire(self): - yield connection - - head = {"id": 7, "work_item_key": "FW-00001", "name": "공종", "status": "partial"} - head |= {"seed": {"name": "공종"}, "source": [], "notes": []} - - async def fetch_item(_c, key, lock=False): - return dict(head) if key == "FW-00001" else None - - async def fetch_rows(_c, _item_id, with_deleted=False): - return [_row(1)] - - async def revision(_c, _scope, lock=False): - return 3 - - async def known_resource_keys(_c, keys): - return keys & {"L-001"} - - async def write(_c, *args): - connection.log.append("write") - - async def bump_revision(_c, _scope): - connection.log.append("bump") - - async def list_items(_c, **picked): - connection.log.append(picked["status"]) - return {"items": [], "total": 2, "counts": {"partial": 2}} - - monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool()) - for name, fake in { - "list_items": list_items, - "fetch_item": fetch_item, - "fetch_rows": fetch_rows, - "revision": revision, - "known_resource_keys": known_resource_keys, - "write": write, - "bump_revision": bump_revision, - }.items(): - monkeypatch.setattr(router_module.repo, name, fake) - app = FastAPI() - app.include_router(router_module.router) - app.dependency_overrides[verify_session] = lambda: {"user_id": 42} - test_client = TestClient(app) - test_client.log = connection.log - return test_client - - -URL = "/api/master-data/work-items/FW-00001" - - -def test_낡은_revision_은_409_이고_아무것도_안_씀(client) -> None: - response = client.put(URL, json={**_body(id=1, resource_key="L-001"), "base_revision": 2}) - assert response.status_code == 409 - assert client.log == ["begin", "rollback"] - - -def test_없는_자원_열쇠는_422_이고_아무것도_안_씀(client) -> None: - response = client.put(URL, json=_body(resource_key="없는열쇠")) - assert response.status_code == 422 and "없는열쇠" in response.json()["detail"] - assert client.log == ["begin", "rollback"] - - -def test_맞는_저장은_쓰고_revision_올리고_새_상세를_줌(client) -> None: - response = client.put(URL, json=_body(id=1, resource_key="L-001", amount=2)) - assert response.status_code == 200, response.text - assert client.log == ["begin", "write", "bump", "commit"] - assert response.json()["status"] == "success" and response.json()["item"]["revision"] == 3 - - -def test_목록은_상태별_개수를_다_채우고_모르는_상태는_막음(client) -> None: - body = client.get("/api/master-data/work-items?status=").json() - assert body["counts"] == {s: 2 if s == "partial" else 0 for s in work_items.STATUSES} - assert client.get("/api/master-data/work-items?status=needs_input").status_code == 200 - assert client.get("/api/master-data/work-items?status=bogus").status_code == 422 - assert client.log == ["", "needs_input"] - - -def test_초기값으로와_없는_공종(client) -> None: - assert client.post(URL + "/reset").status_code == 200 - assert client.log == ["begin", "write", "bump", "commit"] - assert client.get("/api/master-data/work-items/FW-99999").status_code == 404 - - -# ── 등록 ── - - -def _calls(dependant) -> set: - found = set() - for sub in dependant.dependencies: - found.add(sub.call) - found |= _calls(sub) - return found - - -def test_공종_편집_경로는_시스템_관리자만() -> None: - app = importlib.import_module("main").app - routes = {(r.path, m) for r in app.routes if isinstance(r, APIRoute) for m in r.methods} - wanted = { - ("/api/master-data/work-items", "GET"), - ("/api/master-data/work-items/{key}", "GET"), - ("/api/master-data/work-items/{key}", "PUT"), - ("/api/master-data/work-items/{key}/reset", "POST"), - } - assert wanted <= routes - for route in app.routes: - if isinstance(route, APIRoute) and route.path.startswith("/api/master-data/work-items"): - assert {verify_session, require_system_admin} <= _calls(route.dependant) diff --git a/resources/tester/test_z01_work_items.py b/resources/tester/test_z01_work_items.py index ec12ea20..5bce9ff6 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(monkeypatch: pytest.MonkeyPatch) -> TestClient: +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: from common_util.common_util_auth import verify_session - install(monkeypatch) # 기초단가 표 목록·줄 수가 DB 를 읽음 — 가짜로 + monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} @@ -131,15 +131,10 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음 res = client.put( - "/api/master-data/base-prices/work_item_const", - json={ - "base_revision": 0, - "edits": [{"row_key": "CW-00002#01", "column": "name", "value": "바꿈"}], - }, + "/api/master-data/base-prices/work_item_const/CW-00002%2301", + json={"values": {"name": "바꿈"}}, ) - assert ( - res.status_code == 400 and "고칠 수 없는 표" in res.json()["message"] - ) # 공종은 /work-items 몫 + assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"] def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None: diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index fd7cab5e..ccc0850a 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -142,105 +142,68 @@ export const ui_locales_b3 = { Z01_MasterData_Title: ["마스터 데이터", "Master Data"], Z01_MasterData_Tree: ["마스터 트리", "Master tree"], /* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */ + Z01_MasterData_List_Kind: ["표", "Table"], + Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ - "고칠 수 있는 칸 — 눌러 고치고 Enter (Esc 취소) · [저장]을 눌러야 반영", - "Editable — click, type, Enter (Esc cancels) · applied on [Save]", + "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", + "Editable — click, type, Enter to save (Esc cancels)", ], Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], + Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], + Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], + Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], Z01_MasterData_Edit_Invalid: [ "「{value}」 를 수로 못 읽음", "Cannot read 「{value}」 as a number", ], - Z01_MasterData_Edit_NotSaved: ["⚠ 안 들어감 — {value}", "⚠ Not applied — {value}"], + Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"], + Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], + Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], Z01_MasterData_Formula: ["산출근거", "Basis"], - /* --- Z01 고치기 — [저장] 한 번에 보냄(자동저장 금지 · PLAN 1-0) --- */ - Z01_MasterData_Save: ["저장", "Save"], - Z01_MasterData_Discard: ["고친 것 버리기", "Discard edits"], - Z01_MasterData_AddRow: ["줄 추가", "Add row"], - Z01_MasterData_DeleteRow: ["줄 삭제", "Delete row"], - Z01_MasterData_UndeleteRow: ["삭제 취소", "Undo delete"], - Z01_MasterData_Reset: ["초기값으로", "Reset to initial"], - Z01_MasterData_ResetConfirm: [ - "고른 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", - "Reset the picked row to its initial value — saved at once (admin-added rows are removed)", + Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], + Z01_MasterData_State_SourceChanged: [ + "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", + "⚠ Source changed after edit — edited value still used · new source {value}", ], - Z01_MasterData_ResetBlocked: [ - "저장 안 한 고침이 있음 — 먼저 [저장]하거나 [고친 것 버리기]", - "Unsaved edits — save or discard them first", + Z01_MasterData_State_MeaningChanged: [ + "⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)", + "⛔ Edited — the row now means something else (same code, different item)", ], - Z01_MasterData_ResetDone: ["초기값으로 되돌렸음", "Reset to initial value"], - Z01_MasterData_Saved: ["저장했음", "Saved"], - Z01_MasterData_Unsaved: ["저장 안 한 고침이 있음", "There are unsaved edits"], - Z01_MasterData_Draft_Edits: ["고친 칸", "Edited cells"], - Z01_MasterData_Draft_Added: ["추가 줄", "Added rows"], - Z01_MasterData_Draft_Deleted: ["삭제 줄", "Deleted rows"], - Z01_MasterData_Draft_Pending: ["[저장]해야 반영됨", "applied on [Save]"], - Z01_MasterData_Draft_None: ["고친 것 없음", "No edits"], - Z01_MasterData_Draft_Stale: [ - "⚠ 다른 곳에서 먼저 저장됨 — 이 초안은 저장 못 함 · [고친 것 버리기]로 새로 읽을 것", - "⚠ Saved elsewhere first — this draft cannot be saved · discard to reload", + Z01_MasterData_State_MeaningMissing: [ + "⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그", + "⛔ Server sent no before/after row names — bug", ], - Z01_MasterData_Cell_Draft: ["저장 안 한 고침 — {value}", "Unsaved edit — {value}"], - Z01_MasterData_Cell_Changed: ["초기값과 다름(저장된 고침)", "Differs from the initial value"], - Z01_MasterData_Row_Added: ["관리자가 추가한 줄", "Row added by an admin"], - Z01_MasterData_Row_New: ["새 줄 — 저장 전", "New row — not saved yet"], - /* --- Z01 공종 편집 (PLAN 1-4) --- */ - Z01_WorkItem_Title: ["공종 편집", "Work items"], - Z01_WorkItem_Axis_Forest: ["산림 공종", "Forest"], - Z01_WorkItem_Axis_Const: ["건설 공종", "Construction"], - Z01_WorkItem_Pick: ["왼쪽 목록에서 공종을 고를 것", "Pick a work item from the list"], - Z01_WorkItem_Status: ["상태", "Status"], - Z01_WorkItem_Status_All: ["전부", "All"], - Z01_WorkItem_Status_NeedsInput: ["입력 필요", "Needs input"], - Z01_WorkItem_Status_Partial: ["일부 못 맞춤", "Partly unmatched"], - Z01_WorkItem_Status_AutoOk: ["자동 완료", "Auto OK"], - Z01_WorkItem_Status_NotItem: ["공종 아님", "Not a work item"], - Z01_WorkItem_Status_Reviewed: ["검수 완료", "Reviewed"], - Z01_WorkItem_Review: ["검수 완료", "Mark reviewed"], - Z01_WorkItem_Dirty: [ - "저장 안 한 고침 있음 — [저장]해야 반영됨", - "Unsaved edits — applied on [Save]", + Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"], + Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"], + Z01_MasterData_Edit_DropOnMeaning: [ + "고친 값 버리고 지금 줄 값 {value} 쓰기", + "Drop the edit and use the current row value {value}", ], - Z01_WorkItem_ResetConfirm: [ - "이 공종의 머리·구성 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", - "Reset this work item's header and rows — saved at once (admin-added rows are removed)", + Z01_MasterData_Edit_MeaningConfirm: [ + "줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?", + "This row now means something else. Dropping the edit uses the current row's value. Continue?", ], - Z01_WorkItem_Key: ["열쇠", "Key"], - Z01_WorkItem_Number: ["번호", "Number"], - Z01_WorkItem_Parent: ["상위 공종", "Parent"], - Z01_WorkItem_Edition: ["판", "Edition"], - Z01_WorkItem_Name: ["이름", "Name"], - Z01_WorkItem_BasisQuantity: ["기준 수량", "Basis quantity"], - Z01_WorkItem_BasisUnit: ["기준 단위", "Basis unit"], - Z01_WorkItem_Rows: ["구성 줄", "Resource rows"], - Z01_WorkItem_NoVariant: ["갈래 없음", "No variant"], - Z01_WorkItem_VariantName: ["새 갈래 이름", "New variant name"], - Z01_WorkItem_AddVariant: ["갈래 추가", "Add variant"], - Z01_WorkItem_Col_Kind: ["종류", "Kind"], - Z01_WorkItem_Col_Resource: ["자원", "Resource"], - Z01_WorkItem_Col_Spec: ["규격", "Spec"], - Z01_WorkItem_Col_Key: ["기초단가 열쇠", "Base price key"], - Z01_WorkItem_Col_Amount: ["수량", "Amount"], - Z01_WorkItem_Col_Unit: ["단위", "Unit"], - Z01_WorkItem_Col_Alt: ["대체 수량", "Alternative"], - Z01_WorkItem_Col_Ratio: ["묶음 비율 %", "Group ratio %"], - Z01_WorkItem_Col_Raw: ["원문 글자", "Source text"], - Z01_WorkItem_Col_Table: ["품셈 표", "Table"], - Z01_WorkItem_Col_Delete: ["", ""], - Z01_WorkItem_Kind_Labor: ["노무", "Labor"], - Z01_WorkItem_Kind_Machine: ["기계", "Machine"], - Z01_WorkItem_Kind_Material: ["자재", "Material"], - Z01_WorkItem_Kind_Etc: ["기타", "Other"], - Z01_WorkItem_Unmatched: ["⚠ 못 맞춤", "⚠ Unmatched"], - Z01_WorkItem_Find: ["찾기", "Find"], - Z01_WorkItem_FindTitle: ["자원 찾기 — 기초단가에서 골라 이음", "Find resource in base prices"], - Z01_WorkItem_FindClose: ["닫기", "Close"], - Z01_WorkItem_FindNone: ["찾은 줄 없음", "Nothing found"], - Z01_WorkItem_Source: ["품셈 원문 (읽기 전용)", "Standard source (read-only)"], - Z01_WorkItem_NoSource: ["원문 표 없음", "No source table"], - Z01_WorkItem_Notes: ["[주]", "[Notes]"], + Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"], + Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"], + Z01_MasterData_State_Orphan: [ + "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", + "⚠ Orphan — the row disappeared on update", + ], + Z01_MasterData_State_Missing: [ + "⚠ 서버가 판정(state)을 안 줌 — 버그", + "⚠ Server sent no state — bug", + ], + Z01_MasterData_List_State: ["상태", "State"], + Z01_MasterData_List_Row: ["줄", "Row"], + Z01_MasterData_List_Column: ["칸", "Column"], + Z01_MasterData_List_Value: ["고친 값", "Edited value"], + Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], + Z01_MasterData_List_Current: ["지금 원본", "Current source"], + Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], + Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], + Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],