/* ============================================================================= * M01_SL_Eval.ts * 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사 * 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음 * 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; import { nodeSheet, type SheetLogic, type SlNode, type SlOutside, type SlSource, } from "./M01_SL_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ export type SlValue = number | string | boolean | null; /** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */ export interface SlOutsideValue { node: string; cell: string; value: SlValue | SlValue[][]; } export interface SlEvalResult { /** 상자 id → 출력 알약 이름 → 값 */ pills: Record>; /** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */ pillsIn: Record>; result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null }; /** 쉬운 말 */ errors: string[]; } const TEXT = { /** {box} {cell} {err} */ CellError: [ "「{box}」 {cell} 칸 계산이 막힘 — {err}", "「{box}」 cell {cell} can't be worked out — {err}", ], Cycle: ["돌고 도는 참조", "circular reference"], /** {box} {pill} */ EmptyIn: [ "「{box}」의 입력 알약 「{pill}」이 비어 있음", "Input pill 「{pill}」 of 「{box}」 is empty", ], NoOutside: [ "「{box}」의 바깥 값 「{pill}」을 아직 안 받음", "Outside value 「{pill}」 of 「{box}」 not filled yet", ], NoResult: ["결과 상자가 없음", "There is no result box"], /** {cell} {why} */ NoAnswer: [ "{cell} 칸 바깥 값을 못 받음 {why}", "Couldn't get the outside value for {cell} {why}", ], NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"], } as const; const tl = (key: keyof typeof TEXT, fill: Record = {}): string => Object.entries(fill).reduce( (s, [k, v]) => s.replace(`{${k}}`, v), TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, ); /* ── 바깥 값 ────────────────────────────────────────────────────────────── */ /** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */ export function outsideCells(sl: SheetLogic): SlOutside[] { const kindOf = (n: SlNode): SlOutside["kind"] => n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price"; const sourceOf = (n: SlNode): SlSource | undefined => n.source ?? (n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ?? (n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined); return sl.nodes.flatMap((n) => [...n.pillsIn, ...n.pillsOut] .filter((p) => p.outside) .map((p) => { const source = sourceOf(n); const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) }; return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) }; }), ); } /** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */ export interface SlOutsideAnswer { id: string; ok: boolean; value?: number | null; rows?: (string | number | null)[][]; reason?: string; } /** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */ export function answersToValues( cells: SlOutside[], answers: SlOutsideAnswer[], ): { values: SlOutsideValue[]; errors: string[] } { const values: SlOutsideValue[] = []; const errors: string[] = []; for (const c of cells) { const a = answers.find((x) => x.id === c.id); if (!a || !a.ok) { errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); continue; } values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); } return { values, errors }; } /** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ function putValue(칸: Record, cell: string, value: SlValue): void { const style = 칸[cell]?.서식; if (value === null) { if (style) 칸[cell] = { 서식: style }; else delete 칸[cell]; } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; } /** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */ export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic { const out = structuredClone(sl); for (const v of values) { const sheet = nodeSheet(out, v.node); const at = parseA1(v.cell); if (!sheet || !at) continue; if (!Array.isArray(v.value)) { putValue(sheet.칸, toA1(at.r, at.c), v.value); continue; } v.value.forEach((line, dr) => line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)), ); } return out; } /* ── 계산 ───────────────────────────────────────────────────────────────── */ const fromCalc = (v: CalcValue | undefined): SlValue => { if (!v) return null; if ("수" in v) return Number(v.수); if ("글" in v) return v.글; if ("참" in v) return v.참; return v.오류; }; const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v); /** 화면 계산 */ export function evalLogic(sl: SheetLogic): SlEvalResult { const values = recalcWorkbook(sl.workbook); const read = (n: SlNode, cell: string): SlValue => { const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()]; if (!c) return null; if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]); return c.값 ?? null; }; const pills: SlEvalResult["pills"] = {}; const pillsIn: SlEvalResult["pillsIn"] = {}; for (const n of sl.nodes) { pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)])); pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)])); } const errors: string[] = []; for (const n of sl.nodes) for (const [cell, v] of Object.entries(values[n.sheetId] ?? {})) if ("오류" in v) { const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; errors.push(tl("CellError", { box: n.name, cell, err })); } const box = sl.nodes.find((n) => n.kind === "result"); const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null); const result: SlEvalResult["result"] = box ? { 노무비: num(pillsIn[box.id]["노무비"]), 재료비: num(pillsIn[box.id]["재료비"]), 경비: num(pillsIn[box.id]["경비"]), 계: num(pills[box.id]["계"]), } : { 노무비: null, 재료비: null, 경비: null, 계: null }; return { pills, pillsIn, result, errors }; } /** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */ export function checkLogic(sl: SheetLogic): string[] { const out: string[] = []; for (const n of sl.nodes) { const sheet = nodeSheet(sl, n.id); for (const p of [...n.pillsIn, ...n.pillsOut]) { const c = sheet?.칸[p.cell.toUpperCase()]; const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === "")); if (!empty) continue; if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name })); // 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음 else if (n.pillsIn.includes(p) && n.kind !== "result") out.push(tl("EmptyIn", { box: n.name, pill: p.name })); } } const box = sl.nodes.find((n) => n.kind === "result"); if (!box) out.push(tl("NoResult")); else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine")); return [...out, ...evalLogic(sl).errors]; }