feat(Z01): 기초단가 표 [저장] 방식 교체 · 공종 편집 화면 신설 — PLAN 1-4

- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
This commit is contained in:
2026-09-18 23:55:45 +09:00
co-authored by Claude Opus 5
parent 9629430aff
commit feab848e69
10 changed files with 2012 additions and 309 deletions
+159 -37
View File
@@ -2,17 +2,12 @@
* Z01_MasterData_Api_Fetch.ts
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
*
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
* 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄
* (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type {
MasterColumn,
OverrideState,
ServedKind,
SortOrder,
TableMeta,
} from "./Z01_MasterData_UI_Cells";
import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells";
export interface MasterTable {
id: string;
@@ -38,11 +33,16 @@ export interface MasterGroup {
files: MasterFile[];
}
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
/**
* 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴.
* 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음.
*/
export interface MasterRows extends TableMeta {
columns: MasterColumn[];
rows: Record<string, unknown>[];
total: number;
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
revision?: number;
}
export interface RowsQuery {
@@ -65,17 +65,34 @@ export function fetchBaseKinds(): Promise<ServedKind[]> {
return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds);
}
/** 실패 응답 — `status` 409 = 낡은 판(다른 곳에서 먼저 저장함). */
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const data = (await response.json()) as { detail?: string } & T;
if (!response.ok) throw new Error(data.detail ?? "Request failed");
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) {
// 검사 실패(422)는 detail 이 목록으로 옴 — 글자로 펴서 보임(「[object Object]」 금지).
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
throw new ApiError(detail ?? "Request failed", response.status);
}
return data;
}
const send = <T>(method: string, path: string, body: unknown): Promise<T> =>
request<T>(path, { method, body: JSON.stringify(body) });
const pageParams = (query: RowsQuery): Record<string, string> => ({
page: String(query.page),
size: String(query.size),
@@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
}
export interface OverrideItem {
kind: BasePriceKind;
row_id: string;
row_label: string;
column: string;
column_label: string;
original: unknown;
current?: unknown;
value: unknown;
unit?: string;
state: OverrideState;
/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */
export interface BaseSave {
base_revision: number;
edits: { row_key: string; column: string; value: unknown }[];
added: { data: Record<string, unknown> }[];
deleted: string[];
}
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */
export function fetchOverrides(
query: Pick<RowsQuery, "page" | "size">,
): Promise<{ items: OverrideItem[]; total: number }> {
const params = new URLSearchParams({ page: String(query.page), size: String(query.size) });
return request(`/master-data/overrides?${params}`);
export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise<unknown> {
return send("PUT", `/master-data/base-prices/${kind}`, body);
}
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
export function saveBasePrice(
kind: BasePriceKind,
rowId: string,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, {
method: "PUT",
body: JSON.stringify({ values }),
/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */
export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise<unknown> {
return send("POST", `/master-data/base-prices/${kind}/reset`, { row_keys: rowKeys });
}
/* ── 공종 마스터(PLAN 1-0) ── */
export type WorkItemAxis = "forest" | "const";
/** 주입 때 한 번 판정 · `reviewed` 는 관리자만 찍음 */
export type WorkItemStatus = "auto_ok" | "partial" | "needs_input" | "not_item" | "reviewed";
export type ResourceKind = "labor" | "machine" | "material" | "etc";
export interface WorkItemListRow {
work_item_key: string;
number: string;
path_name: string;
status: WorkItemStatus;
row_count: number;
}
export interface WorkItemList {
items: WorkItemListRow[];
total: number;
/** 상태별 개수(검색·상태 거르기 전 그 축 전체) */
counts: Partial<Record<WorkItemStatus, number>>;
}
export interface WorkItemListQuery {
axis: WorkItemAxis;
status: WorkItemStatus | "";
q: string;
page: number;
size: number;
}
export function fetchWorkItems(query: WorkItemListQuery): Promise<WorkItemList> {
const params = new URLSearchParams({
axis: query.axis,
q: query.q,
page: String(query.page),
size: String(query.size),
});
if (query.status) params.set("status", query.status);
return request<WorkItemList>(`/master-data/work-items?${params}`);
}
/** 머리 중 관리자가 고치는 칸 — PUT 에 이것만 보냄 */
export interface WorkItemHead {
name: string;
basis_quantity: number | null;
basis_unit: string | null;
status: WorkItemStatus;
}
export interface WorkItemItem extends WorkItemHead {
work_item_key: string;
axis: WorkItemAxis;
code: string | null;
number: string;
path_name: string;
parent_key: string | null;
parent_mode: string | null;
edition: string | null;
revision: number;
/** 머리 중 초기값과 다른 칸(저장된 고침) */
changed_columns?: string[];
}
/** 구성 줄 — id 없으면 새 줄 · resource_key 없으면 기초단가와 못 맞춘 줄(raw_cell 에 원문과 까닭) */
export interface WorkItemRow {
id?: number;
variant: string;
resource_kind: ResourceKind;
resource_key: string | null;
resource_name: string;
resource_spec: string;
amount: number | null;
amount_unit: string;
alternative_amount: number | null;
group_ratio_pct: number | null;
raw_cell: string | null;
pum_table_id: string | null;
/** GET 만 — 관리자가 넣은 줄 · 초기값과 다른 칸 */
is_added?: boolean;
changed_columns?: string[];
}
/** 품셈 원문 표 한 벌 — condition_note = 머리 글자 · raw_row = 원문 칸(읽기 전용) */
export interface WorkItemSource {
pum_table_id: string;
section?: string | null;
pum_form?: string | null;
condition_note?: unknown;
raw_row?: unknown;
}
export interface WorkItemDetail {
item: WorkItemItem;
rows: WorkItemRow[];
source: WorkItemSource[];
notes: unknown[];
}
/** 공종 [저장] — rows 는 전체 줄(id 있으면 수정 · 없으면 추가 · 빠지면 삭제) · 낡은 판이면 409 */
export interface WorkItemSave {
base_revision: number;
item: WorkItemHead;
rows: WorkItemRow[];
}
const workItemPath = (key: string) => `/master-data/work-items/${encodeURIComponent(key)}`;
export function fetchWorkItem(key: string): Promise<WorkItemDetail> {
return request<WorkItemDetail>(workItemPath(key));
}
/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */
export function saveWorkItem(key: string, body: WorkItemSave): Promise<WorkItemDetail> {
return send("PUT", workItemPath(key), body);
}
/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */
export function resetWorkItem(key: string): Promise<WorkItemDetail> {
return send("POST", `${workItemPath(key)}/reset`, {});
}
export async function fetchMasterTree(): Promise<MasterGroup[]> {