- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(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
263 lines
8.9 KiB
TypeScript
263 lines
8.9 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, unknown>[];
|
|
total: number;
|
|
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
|
|
revision?: number;
|
|
}
|
|
|
|
export interface RowsQuery {
|
|
page: number;
|
|
size: number;
|
|
q: string;
|
|
/** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */
|
|
sort?: SortOrder | null;
|
|
/** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */
|
|
filters?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움
|
|
* (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리).
|
|
*/
|
|
export type BasePriceKind = string;
|
|
|
|
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().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),
|
|
q: query.q,
|
|
});
|
|
|
|
export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<MasterRows> {
|
|
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<MasterRows>(`/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<string, unknown> }[];
|
|
deleted: string[];
|
|
}
|
|
|
|
export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise<unknown> {
|
|
return send("PUT", `/master-data/base-prices/${kind}`, body);
|
|
}
|
|
|
|
/** 고른 줄을 초기값(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[]> {
|
|
return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups;
|
|
}
|
|
|
|
export function fetchMasterRows(
|
|
file: string,
|
|
table: string,
|
|
query: RowsQuery,
|
|
): Promise<MasterRows> {
|
|
const params = new URLSearchParams({ file, table, ...pageParams(query) });
|
|
return request<MasterRows>(`/master-data/rows?${params}`);
|
|
}
|