/* ============================================================================= * 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"], /** {box} */ HeadChanged: [ "「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함", "Table headers of 「{box}」 changed — choose the table again", ], /** {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[], sl?: SheetLogic, ): { 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; } const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null; if (head) errors.push(head); values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); } return { values, errors }; } /** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */ function headChanged( sl: SheetLogic, c: SlOutside, head: (string | number | null)[], ): string | null { const sheet = nodeSheet(sl, c.node); const at = parseA1(c.cell); if (!sheet || !at) return null; const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null); if (now.every((x) => x === null)) return null; // 처음 채움 const same = head.length === now.length && head.every((h, i) => h === now[i]); const box = sl.nodes.find((n) => n.id === c.node)?.name ?? ""; return same ? null : tl("HeadChanged", { box }); } /** 칸 하나에 값 — 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)), ); // 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림 const width = Math.max(0, ...v.value.map((line) => line.length)); const last = at.r + v.value.length - 1; for (const a of Object.keys(sheet.칸)) { const p = parseA1(a); const c = sheet.칸[a]; if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined) putValue(sheet.칸, a, null); } } 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]; }