From f2fe4f42145c78a4167f4829c8591a6de58160f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 19 Sep 2026 01:36:21 +0900 Subject: [PATCH] =?UTF-8?q?revert(Z01):=20=EB=A1=A4=EB=B0=B1=EB=90=9C=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20DB=C2=B7=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9D=B4=20dev=20=EB=A1=9C=20=EB=90=98?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EC=98=A8=20=EA=B2=83=20=EA=B1=B7=EC=96=B4?= =?UTF-8?q?=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 「완전히 되돌려」 뒤 dev 를 거쳐 다시 들어온 1-2·1-3·1-4 코드 26 경로를 9629430a 모습으로(고친 17 되돌림 · 새로 생긴 9 지움) - 대상: Z01_MasterData 17 · main.py · ui_template_locale_b3.ts · resources/tester 의 Z01 시험 7 - 품셈 md·도구는 안 건드림 · 이력 재작성 없음(되돌림 커밋) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69 --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 196 ++----- Z01_MasterData/Z01_MasterData_BasePrices.py | 269 ++++----- .../Z01_MasterData_BasePrices_Machine.py | 34 +- .../Z01_MasterData_BasePrices_Tables.py | 2 +- Z01_MasterData/Z01_MasterData_Overrides.py | 202 +++++++ .../Z01_MasterData_Repository_BasePrices.py | 168 ------ .../Z01_MasterData_Repository_WorkItems.py | 181 ------ Z01_MasterData/Z01_MasterData_Router.py | 152 ++--- .../Z01_MasterData_Router_WorkItems.py | 152 ----- Z01_MasterData/Z01_MasterData_UI_Draft.ts | 263 --------- Z01_MasterData/Z01_MasterData_UI_Page.ts | 29 +- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 437 +++++--------- Z01_MasterData/Z01_MasterData_UI_Side.ts | 95 ++- Z01_MasterData/Z01_MasterData_UI_Style.css | 201 ------- Z01_MasterData/Z01_MasterData_UI_WorkItem.ts | 409 ------------- .../Z01_MasterData_UI_WorkItem_Rows.ts | 374 ------------ Z01_MasterData/Z01_MasterData_WorkItems.py | 132 ----- main.py | 2 - resources/tester/helper_z01_fake_repo.py | 92 --- .../tester/test_master_labels_base_prices.py | 39 +- resources/tester/test_z01_base_prices.py | 554 +++++++++--------- resources/tester/test_z01_base_tables.py | 60 +- resources/tester/test_z01_master_draft.py | 184 ------ resources/tester/test_z01_work_item_edit.py | 235 -------- resources/tester/test_z01_work_items.py | 17 +- ui_template/ui_template_locale_b3.ts | 131 ++--- 26 files changed, 1012 insertions(+), 3598 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_Overrides.py delete mode 100644 Z01_MasterData/Z01_MasterData_Repository_BasePrices.py delete mode 100644 Z01_MasterData/Z01_MasterData_Repository_WorkItems.py delete mode 100644 Z01_MasterData/Z01_MasterData_Router_WorkItems.py delete mode 100644 Z01_MasterData/Z01_MasterData_UI_Draft.ts delete mode 100644 Z01_MasterData/Z01_MasterData_UI_WorkItem.ts delete mode 100644 Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts delete mode 100644 resources/tester/helper_z01_fake_repo.py delete mode 100644 resources/tester/test_z01_master_draft.py delete mode 100644 resources/tester/test_z01_work_item_edit.py 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/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"],