/* ============================================================================= * Z01_MasterData_Api_Fetch.ts * 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만 * * 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄 * (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; label: string; key: string; row_count: number; } export interface MasterFile { id: string; label: string; key: string; tables: MasterTable[]; } /** 표가 아닌 낱값(한 값짜리 요율 따위)을 모은 마디 — 여느 표처럼 이름·값 두 칸으로 옴. */ export const VALUES_TABLE_ID = "@values"; export interface MasterGroup { /** fingerprint = 폴더 지문(_manifest) — 어느 판으로 셈했는지 못박는 자리(부산물 아님). */ key: "logic" | "base_value" | "byproduct" | "seed" | "fingerprint"; label: string; files: MasterFile[]; } /** * 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴. * 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음. */ export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; /** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */ revision?: number; } export interface RowsQuery { page: number; size: number; q: string; /** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */ sort?: SortOrder | null; /** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */ filters?: Record; } /** * 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움 * (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리). */ export type BasePriceKind = string; 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); } 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), q: query.q, }); export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise { const params = new URLSearchParams(pageParams(query)); // 안 누르면 sort 를 안 보냄 — 서버가 원본 차례로 줌(요율·기계는 차례가 뜻을 담음). if (query.sort) { params.set("sort", query.sort.key); params.set("desc", query.sort.desc ? "1" : "0"); } for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value); 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 function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise { return send("PUT", `/master-data/base-prices/${kind}`, body); } /** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */ export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise { return send("POST", `/master-data/base-prices/${kind}/reset`, { row_keys: rowKeys }); } /* ── 공종 마스터(PLAN 1-0) ── */ export type WorkItemAxis = "forest" | "const"; /** 주입 때 한 번 판정 · `reviewed` 는 관리자만 찍음 */ export type WorkItemStatus = "auto_ok" | "partial" | "needs_input" | "not_item" | "reviewed"; export type ResourceKind = "labor" | "machine" | "material" | "etc"; export interface WorkItemListRow { work_item_key: string; number: string; path_name: string; status: WorkItemStatus; row_count: number; } export interface WorkItemList { items: WorkItemListRow[]; total: number; /** 상태별 개수(검색·상태 거르기 전 그 축 전체) */ counts: Partial>; } export interface WorkItemListQuery { axis: WorkItemAxis; status: WorkItemStatus | ""; q: string; page: number; size: number; } export function fetchWorkItems(query: WorkItemListQuery): Promise { const params = new URLSearchParams({ axis: query.axis, q: query.q, page: String(query.page), size: String(query.size), }); if (query.status) params.set("status", query.status); return request(`/master-data/work-items?${params}`); } /** 머리 중 관리자가 고치는 칸 — PUT 에 이것만 보냄 */ export interface WorkItemHead { name: string; basis_quantity: number | null; basis_unit: string | null; status: WorkItemStatus; } export interface WorkItemItem extends WorkItemHead { work_item_key: string; axis: WorkItemAxis; code: string | null; number: string; path_name: string; parent_key: string | null; parent_mode: string | null; edition: string | null; revision: number; /** 머리 중 초기값과 다른 칸(저장된 고침) */ changed_columns?: string[]; } /** 구성 줄 — id 없으면 새 줄 · resource_key 없으면 기초단가와 못 맞춘 줄(raw_cell 에 원문과 까닭) */ export interface WorkItemRow { id?: number; variant: string; resource_kind: ResourceKind; resource_key: string | null; resource_name: string; resource_spec: string; amount: number | null; amount_unit: string; alternative_amount: number | null; group_ratio_pct: number | null; raw_cell: string | null; pum_table_id: string | null; /** GET 만 — 관리자가 넣은 줄 · 초기값과 다른 칸 */ is_added?: boolean; changed_columns?: string[]; } /** 품셈 원문 표 한 벌 — condition_note = 머리 글자 · raw_row = 원문 칸(읽기 전용) */ export interface WorkItemSource { pum_table_id: string; section?: string | null; pum_form?: string | null; condition_note?: unknown; raw_row?: unknown; } export interface WorkItemDetail { item: WorkItemItem; rows: WorkItemRow[]; source: WorkItemSource[]; notes: unknown[]; } /** 공종 [저장] — rows 는 전체 줄(id 있으면 수정 · 없으면 추가 · 빠지면 삭제) · 낡은 판이면 409 */ export interface WorkItemSave { base_revision: number; item: WorkItemHead; rows: WorkItemRow[]; } const workItemPath = (key: string) => `/master-data/work-items/${encodeURIComponent(key)}`; export function fetchWorkItem(key: string): Promise { return request(workItemPath(key)); } /** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */ export function saveWorkItem(key: string, body: WorkItemSave): Promise { return send("PUT", workItemPath(key), body); } /** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */ export function resetWorkItem(key: string): Promise { return send("POST", `${workItemPath(key)}/reset`, {}); } export async function fetchMasterTree(): Promise { return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups; } export function fetchMasterRows( file: string, table: string, query: RowsQuery, ): Promise { const params = new URLSearchParams({ file, table, ...pageParams(query) }); return request(`/master-data/rows?${params}`); }