/* ============================================================================= * M01_MasterData_UI_Logic_Drive.ts * 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀. * `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움 * `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈 * 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림 * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { fetchAuto, runCalc, runText, type CalcAnswer, type CalcLine, type LogicRow, type TextAnswer, } from "./M01_MasterData_UI_Logic_Api"; import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; export interface DriveContext { /** 저장된 키 — 새 로직은 null */ savedKey: string | null; /** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */ file?: string; row: LogicRow; dirty: () => boolean; /** 로직마다 넣은 값 — 다시 그려도 남음 */ values: Record; onLines?: (lines: CalcLine[] | null) => void; /** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */ onText?: (answer: TextAnswer) => void; } /** 칸에 든 값 → 엔진 입력 — 빈 칸은 안 보냄(엔진이 「입력 없음」 으로 멈춤) · 고르기는 원래 값 그대로 */ function inputsOf(row: LogicRow, values: Record): Record { const inputs: Record = {}; for (const spec of row.입력 ?? []) { const raw = (values[spec.이름] ?? "").trim(); if (raw === "") continue; const option = spec.고르기?.find((o) => String(o) === raw); // 글 "35" 로 보내면 「고르기 밖」 inputs[spec.이름] = option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); } return inputs; } const draftBody = (ctx: DriveContext): { row?: LogicRow; file?: string } => ctx.dirty() || ctx.savedKey === null ? { row: ctx.row, file: ctx.file } : {}; /** 한 번 셈 — 답을 `onText` → `onLines` 차례로 알리고 돌려줌(`draft` = 저장 전 고침으로 셈) */ export async function driveRun( ctx: DriveContext, inputs = inputsOf(ctx.row, ctx.values), ): Promise<{ answer: CalcAnswer; draft: boolean }> { const draft = ctx.dirty() || ctx.savedKey === null; const body = { key: ctx.savedKey ?? ctx.row.키, inputs, ...pickBody(ctx.values), ...draftBody(ctx), }; const answer = await runCalc(body); if (ctx.onText) ctx.onText(await runText(body)); ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null); return { answer, draft }; } /** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */ export function previewText(ctx: DriveContext, live: () => boolean): void { if (!ctx.onText) return; const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) }; void runText(body).then((answer) => { if (!live()) return; (answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false; ctx.onText?.(answer); }); } /** 자동값으로 빈 칸 채움 — 채웠으면 true */ export async function autoFill(ctx: DriveContext): Promise { if (ctx.savedKey === null) return false; const auto = await fetchAuto(ctx.savedKey); const names = (ctx.row.입력 ?? []) .map((spec) => spec.이름) .filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim()); for (const n of names) ctx.values[n] = String(auto[n]); return names.length > 0; } /** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */ const first = new WeakSet>(); /** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */ export function createDrive(): { start: (ctx: DriveContext) => void; run: () => Promise; stop: () => void; } { let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름 let now: DriveContext | null = null; let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음 const run = async (): Promise => { const ctx = now; if (!ctx) return; ran = true; try { await driveRun(ctx); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; const start = (ctx: DriveContext): void => { now = ctx; ran = false; const mine = ++seq; previewText(ctx, () => !ran && mine === seq); if (first.has(ctx.values)) return; if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) { first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈 queueMicrotask(() => mine === seq && void run()); } else if (ctx.savedKey !== null) { first.add(ctx.values); void autoFill(ctx).then((filled) => filled && mine === seq && void run()); } }; return { start, run, stop: () => { now = null; seq++; }, }; }