- master_material.label = 이름 + 지역 칸 · 검색어 지역 낱말 · 자재지역 입력 · 고르는 차례(master_pick)가 이름 글 대신 지역 칸과 맞대짐 - 로직 1,520: 같음 1,517 · 전부터 멈춤 3(GC001199 ~ 1201) — 지역 칸만 고쳤을 때 다르던 11(시멘트 전국 · 포항 모래 · 포항 레미콘)이 원래 줄로 - 계산 줄 품목 칸에 지역 · 상하차 · M01 고르기 창(가격 연결 · 로직 재료 후보) 글에 지역 · 상하차(모름 「-」 뺌) - M01 표 찾기(Store_Find) 낱말이 지역 칸에도 맞음 — 「레미콘 서울」 · 저장 뒤 찾기 기억 지움도 지역 칸 포함 - 새 줄 지역 기본 「-」 · test_m01_pick 합판 서울 기대를 지역 칸으로 - check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_Api_Fetch.ts
|
|
* 마스터 요소 화면 서버 호출 — 계약 `resources/master_data/_화면_계약.md` · 시스템 관리자만
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
|
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
|
|
|
|
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
|
|
export type Row = Record<string, unknown>;
|
|
|
|
export interface GroupInfo {
|
|
group: string;
|
|
files: number;
|
|
rows: number;
|
|
}
|
|
|
|
export interface FileInfo {
|
|
file: string;
|
|
book: string | null;
|
|
chapter: string;
|
|
edition: string | null;
|
|
rows: number;
|
|
version: string;
|
|
}
|
|
|
|
/** 하위 거름 한 갈래 — 인력·기계 모두 「구분」 · 장 그룹은 원문+부문(서버가 모음). */
|
|
export interface SubInfo {
|
|
name: string;
|
|
book: string | null;
|
|
details: string[];
|
|
}
|
|
|
|
export interface RowsPage {
|
|
file: string;
|
|
version: string;
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
rows: Row[];
|
|
/** 참조 칸 키 → 이름(키 옆에 같이 보임) */
|
|
refs: Record<string, string>;
|
|
}
|
|
|
|
/** 소요량·계수 표의 용도 — 공종(원문 절 이름) · 대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) · 이 표를 쓰는 로직 키. */
|
|
export interface TableUsage {
|
|
공종: string;
|
|
대상: string[];
|
|
로직키: string[];
|
|
}
|
|
|
|
export interface TableHead {
|
|
file: string;
|
|
키: string;
|
|
원문번호: string;
|
|
구분: string;
|
|
상세구분: string;
|
|
이름: string;
|
|
기준: string;
|
|
출처: string;
|
|
비고?: string;
|
|
조건: Record<string, string>;
|
|
값칸: Record<string, string>;
|
|
count: number;
|
|
}
|
|
|
|
export interface TablesPage {
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
tables: TableHead[];
|
|
}
|
|
|
|
export interface Change {
|
|
op: "edit" | "add" | "delete";
|
|
key?: string;
|
|
row?: Row;
|
|
}
|
|
|
|
export interface FileChanges {
|
|
file: string;
|
|
version: string;
|
|
changes: Change[];
|
|
}
|
|
|
|
/** 저장 결과 — 200 은 새 판본 · 나머지는 화면이 그대로 안내할 상태. */
|
|
export type SaveResult =
|
|
| { status: 200; files: { file: string; version: string }[] }
|
|
| { status: 409; stale: string[] }
|
|
| { status: 422; errors: string[] }
|
|
| { status: number; detail: string };
|
|
|
|
async function call<T>(
|
|
path: string,
|
|
init: RequestInit = {},
|
|
base = "/m01",
|
|
): Promise<{ status: number; body: T }> {
|
|
const response = await fetch(`${API_BASE_URL}${base}${path}`, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...init,
|
|
});
|
|
return { status: response.status, body: (await response.json()) as T };
|
|
}
|
|
|
|
async function get<T>(
|
|
path: string,
|
|
params: Record<string, string | number>,
|
|
base = "/m01",
|
|
): Promise<T> {
|
|
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
|
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`, {}, base);
|
|
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
|
|
return body;
|
|
}
|
|
|
|
export const fetchGroups = async (): Promise<GroupInfo[]> =>
|
|
(await get<{ groups: GroupInfo[] }>("/groups", {})).groups;
|
|
|
|
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
|
|
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
|
|
|
|
/** 조달청 제비율 엑셀 책(요율 화면 · 읽기만) — 대응 = RT 키 → 대응 칸(시트 id · 첫 칸 · 칠할 범위). */
|
|
export interface RateSheetData {
|
|
원천: { 파일: string; sha256: string; 시트: string[] };
|
|
대응: Record<string, { 시트: string; 칸: string; 범위: string }>;
|
|
책: Workbook;
|
|
}
|
|
|
|
export const fetchRateSheet = (): Promise<RateSheetData> => get<RateSheetData>("/rate-sheet", {});
|
|
|
|
export const fetchRows = (
|
|
file: string,
|
|
page: number,
|
|
size: number,
|
|
q: string,
|
|
unlinked = false,
|
|
sub = "",
|
|
detail = "",
|
|
state = "",
|
|
): Promise<RowsPage> =>
|
|
get<RowsPage>(
|
|
"/rows",
|
|
{ file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state },
|
|
logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1)
|
|
);
|
|
|
|
/** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */
|
|
export const fetchSubsAll = (where: {
|
|
file?: string;
|
|
group?: string;
|
|
}): Promise<{ subs: SubInfo[]; states?: string[] }> =>
|
|
get("/subs", { file: where.file ?? "", group: where.group ?? "" });
|
|
|
|
export const fetchSubs = async (where: { file?: string; group?: string }): Promise<SubInfo[]> =>
|
|
(await fetchSubsAll(where)).subs;
|
|
|
|
export const fetchTables = (
|
|
group: string,
|
|
sub: string,
|
|
detail: string,
|
|
q: string,
|
|
page: number,
|
|
size: number,
|
|
usage = "",
|
|
): Promise<TablesPage> => get("/tables", { group, sub, detail, q, page, size, usage });
|
|
|
|
export const fetchTable = (
|
|
file: string,
|
|
key: string,
|
|
): Promise<{ version: string; table: Row & { 줄: Row[] } }> => get("/table", { file, key });
|
|
|
|
export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
|
|
const { status, body } = await call<Record<string, unknown>>("/save", {
|
|
method: "POST",
|
|
body: JSON.stringify({ files }),
|
|
});
|
|
const detail = body.detail as { stale?: string[]; errors?: string[] } | string | undefined;
|
|
if (status === 200) return { status, files: body.files as { file: string; version: string }[] };
|
|
if (status === 409 && typeof detail === "object") return { status, stale: detail.stale ?? [] };
|
|
if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] };
|
|
return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) };
|
|
}
|
|
|
|
export type PickKind = "price" | "job";
|
|
|
|
export interface PickItem {
|
|
ref: string;
|
|
이름: string;
|
|
규격: string;
|
|
단위: string;
|
|
지역?: string | null;
|
|
상하차?: string | null;
|
|
값: unknown;
|
|
관급: boolean;
|
|
}
|
|
|
|
/** 재료 줄 지역 · 상하차 글(830-10 · 요구 839) — 「-」(모름)은 뺌 · 둘 다 없으면 빈 글 */
|
|
export const placeNote = (it: { 지역?: string | null; 상하차?: string | null }): string =>
|
|
[it.지역, it.상하차].filter((v) => v && v !== "-").join(" · ");
|
|
|
|
export const fetchPick = (
|
|
kind: PickKind,
|
|
q: string,
|
|
): Promise<{ total: number; items: PickItem[] }> =>
|
|
get("/pick", { kind, q, limit: 50 }, logicApiPaths().read);
|
|
|
|
/** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */
|
|
export const searchMaterials = async (
|
|
q: string,
|
|
): Promise<{ 코드: string; 이름: string; 규격: string; 단위: string }[]> =>
|
|
(await fetchPick("price", q)).items.map((m) => ({
|
|
코드: m.ref,
|
|
이름: m.이름,
|
|
규격: m.규격,
|
|
단위: m.단위,
|
|
}));
|