/* ============================================================================= * M01_EasyCalc_Api.ts * 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 · * 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값 * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀) * 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { ApiError, copyLogic, fetchAuto, fetchLogic, runCalc, type CalcAnswer, type LogicInput, type LogicRow, } from "../M01_MasterData_UI_Logic_Api"; import { safeReason } from "../M01_MasterData_UI_Logic_Text"; /** 목록 한 줄 */ export interface EasyListItem { key: string; name: string; /** 품셈 기본(읽기만) · false = 내가 만든 것 */ isBase: boolean; /** 화면 글 「품셈 기본」 · 「내가 만든 것」 */ owner: string; /** 결과단위 「원/㎡」 */ resultUnit: string; /** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */ canOpen: boolean; /** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */ whyNot: string | null; /** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */ where: string; } /** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */ export interface EasyOpened { row: LogicRow; version: string; isBase: boolean; trial: Record; } /** 새로 · 본뜨기 · 고치기 답 */ export interface EasySaved { key: string; version: string; row: LogicRow; } /** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직 */ export interface EasyUse { kind: "combo" | "logic"; key: string; name: string; } /** 기계 고르기 목록 한 줄 */ export interface EasyMachine { 분류번호: string; 이름: string; 규격: string; /** 줄 단위(「시간」) */ 단위: string; } /** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */ export interface EasyMachineElement { element: string; needInputs: LogicInput[]; } /** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */ export interface MachineLogic { key: string; name: string; resultUnit: string; inputs: LogicInput[]; } /* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */ const TEXT = { Base: ["품셈 기본", "Standard"], Mine: ["내가 만든 것", "Mine"], NotMoney: [ "금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄", "Gives a non-money value (output etc.) — open it in the full screen", ], E403: [ "품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침", "Standard items are read-only — use “Edit a copy”", ], E404: [ "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", "Not found — it may be deleted; reopen the list", ], E409: [ "다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요", "Changed elsewhere first — reopen and edit again", ], Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"], Generic: [ "저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요", "Did not pass the check — review your changes", ], More: ["그 밖 {n} 건", "{n} more"], UnknownName: [ "「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요", "「{name}」 is not defined — add it as a design value", ], NeedCost: [ "넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요", "Pick where it goes (labor · material · equipment)", ], NoLines: [ "줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요", "No lines — add at least one labor, material or machine line", ], Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"], CallNeeds: [ "부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요", "The called item needs 「{name}」 — add it as a design value", ], CallExtra: [ "부르는 다른 일위대가는 「{name}」 값을 받지 않음", "The called item does not take 「{name}」", ], Kind: [ "고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요", "The picked item does not match the line type — pick again", ], Loop: [ "서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요", "Items call each other — remove one side", ], Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"], Gone: [ "이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음", "A linked item is missing — items still in use cannot be deleted", ], NoName: ["이름을 넣어 주세요", "Enter a name"], Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"], Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"], Material: [ "재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요", "No material matches — pick again", ], Stop: [ "이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요", "Cannot be calculated with these values — check the design values", ], NoMachine: [ "이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요", "No hourly cost data for this machine — pick another", ], } as const satisfies Record; type SayKey = keyof typeof TEXT; function say(key: SayKey, fill: Record = {}): string { const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); } /** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */ function plainCheck(line: string): string { const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null; const unknown = name(/모르는 이름 「([^」]+)」/); if (unknown) return say("UnknownName", { name: unknown }); const needs = name(/입력 「([^」]+)」 안 줌/); if (needs) return say("CallNeeds", { name: needs }); const extra = name(/에 입력 「([^」]+)」 없음/); if (extra) return say("CallExtra", { name: extra }); if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines"); if (/비목 「/.test(line)) return say("NeedCost"); if (/출처 모양/.test(line)) return say("Source"); if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind"); if (/돌고 도는 참조/.test(line)) return say("Loop"); if (/식 못 읽음/.test(line)) return say("Unread"); if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone"); if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName"); if (/끝수/.test(line)) return say("Cut"); if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material"); if (/칸 「[^」]+」 없음/.test(line)) return say("Broken"); return say("Generic"); } /** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */ function plainOf(status: number, detail: unknown): string { if (status === 0) return say("Net"); if (status === 403) return say("E403"); if (status === 404) return say("E404"); if (status === 409) return say("E409"); const body = detail as { errors?: unknown } | null; const lines = status === 422 && body && Array.isArray(body.errors) ? body.errors.map((x) => String(x)) : typeof detail === "string" ? [detail] : []; if (!lines.length) return say("Generic"); const plain = [...new Set(lines.map(plainCheck))]; const shown = plain.slice(0, 3); if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 })); return shown.join("\n"); } /** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */ function plainStop(text: string): string { return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text; } const MACHINE = "기계"; /** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */ const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]); /** 그 로직이 받는 기종(분류번호) 목록 */ function machineCodes(m: MachineLogic): string[] { const spec = m.inputs.find((i) => i.이름 === MACHINE); return (spec?.고르기 ?? []).map(String); } /** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터. * 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */ function machineRank(m: MachineLogic): number[] { const names = m.inputs.map((i) => i.이름); const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0; const lossOnly = names.includes("지역") ? 0 : 1; const boost = lossOnly && names.includes("보정작업") ? 1 : 0; return [attach, lossOnly, boost, machineCodes(m).length]; } /** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */ export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null { const ranked = family .filter((m) => machineCodes(m).includes(code)) .map((m) => ({ m, rank: machineRank(m) })) .sort((a, b) => { for (let i = 0; i < a.rank.length; i += 1) { if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i]; } return a.m.key < b.m.key ? -1 : 1; }); return ranked[0]?.m ?? null; } /** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */ export function machineCall(m: MachineLogic, code: string): EasyMachineElement { const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE); const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs }; } /* ── 순수 끝 ── */ const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`) const OWNER = "현장"; const CALC_WAIT_MS = 300; const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」) async function request(path: string, body?: unknown): Promise { let response: Response; try { response = await fetch(`${API_BASE_URL}/m01${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch { throw new ApiError(0, ""); } const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); return data; } const query = (params: Record): string => new URLSearchParams(params).toString(); interface Summary { file: string; 키: string; 구분: string; 상세구분: string; 이름: string; 결과단위: string; } const isMoney = (unit: string): boolean => unit.startsWith("원"); /** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */ export async function listEasy(q: string): Promise { const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`); const items = got.logics.map((s): EasyListItem => { const isBase = s.file !== OWN; const canOpen = isMoney(s.결과단위); return { key: s.키, name: s.이름, isBase, owner: say(isBase ? "Base" : "Mine"), resultUnit: s.결과단위, canOpen, whyNot: canOpen ? null : say("NotMoney"), where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분, }; }); return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)]; } /** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */ export async function openEasy(key: string): Promise { const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]); return { row: one.logic, version: one.version, isBase: one.file !== OWN, trial: trialFor(one.logic, auto), }; } /** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */ function trialFor(row: LogicRow, trial: Record): Record { return Object.fromEntries( (row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]), ); } let calcSeq = 0; let calcTimer: ReturnType | undefined; /** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ export function calcLater( row: LogicRow, trial: Record, onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void { const mine = ++calcSeq; clearTimeout(calcTimer); calcTimer = setTimeout(() => { runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row }) .then((ans) => { if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason))); }) .catch((e: unknown) => { if (mine === calcSeq) onDone(null, plainError(e)); }); }, CALC_WAIT_MS); } /** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */ export async function newEasy(row: LogicRow): Promise { const logic = { ...row, 출처: row.출처 || "자체" }; const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", { logic, owner: OWNER, }); return { key: got.key, version: got.version, row: got.logic }; } /** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */ export async function copyEasy(key: string): Promise { const got = await copyLogic(key); return { key: got.key, version: got.version, row: got.logic }; } /** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */ export async function editEasy(key: string, version: string, row: LogicRow): Promise { const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", { key, version, logic: row, }); return { key, version: got.version, row: got.logic }; } /** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */ export async function deleteEasy(key: string, version: string): Promise<{ version: string }> { const got = await request<{ version: string }>("/logic/delete", { key, version }); return { version: got.version }; } /** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것. * ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) — * 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */ export async function usesOf(key: string): Promise { const [combos, mine] = await Promise.all([ request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`), request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`), ]); const call = `로직(${key}`; const others = mine.logics.filter((s) => s.키 !== key); const rows = await Promise.all(others.map((s) => fetchLogic(s.키))); const callers = rows .filter((one) => JSON.stringify(one.logic).includes(call)) .map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 })); return [ ...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })), ...callers, ]; } let family: Promise | null = null; /** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */ function machineFamily(): Promise { family ??= request<{ logics: Summary[] }>("/logics") .then((got) => Promise.all( got.logics .filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위)) .map((s) => fetchLogic(s.키)), ), ) .then((all) => all .map((one) => ({ key: one.logic.키, name: one.logic.이름, resultUnit: one.logic.결과단위, inputs: one.logic.입력 ?? [], })) .filter((m) => machineCodes(m).length > 0), ) .catch((e: unknown) => { family = null; throw e; }); return family; } interface MachineRow { 원문번호?: string; 이름?: string; 규격?: string; } /** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */ export async function listMachines(q: string): Promise { const [all, got] = await Promise.all([ machineFamily(), request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`), ]); return got.items.flatMap((row): EasyMachine[] => { const code = String(row.원문번호 ?? ""); const m = pickMachine(all, code); if (!m) return []; const unit = m.resultUnit.split("/")[1] ?? ""; return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }]; }); } /** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */ export async function machineElement(기종: string): Promise { const m = pickMachine(await machineFamily(), 기종); if (!m) throw new Error(say("NoMachine")); return machineCall(m, 기종); } /** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */ export function plainError(e: unknown): string { if (e instanceof ApiError) return plainOf(e.status, e.detail); if (e instanceof Error && e.message === say("NoMachine")) return e.message; return say("Net"); }