/* ============================================================================= * ui_template_sheet_recalc.ts * 표 문서 한 벌 풀이 — 계산 열 · 한 칸만 다른 식(`줄.식`) · 합계 줄(열마다 다른 식) · 변수. * * 화면(조작 중 즉시)과 서버(`common_util_sheet_recalc_node.ts` — [저장] 때 정본 재계산)가 * **이 파일 하나**를 같이 씀(CLAUDE.md 5장 ②). 두 벌로 짜면 끝수가 조용히 갈림. * 순환은 풀면서 잡음 — 풀고 있는 칸을 다시 부르면 그 고리의 칸마다 오류. * 오류 칸은 값 없이 `오류` 에 까닭과 함께 — 나머지 칸은 계속 풂. * ========================================================================== */ import { type Frac, FormulaError, fracToString, roundAt, toFrac, ZERO, } from "./ui_template_sheet_frac"; import { evaluate, parseFormula, type Node, type Scope, type Value, } from "./ui_template_sheet_formula"; import type { SheetColumn, SheetDoc, SheetError, SheetResult, SheetRounding, SheetTotal, } from "./ui_template_sheet_types"; const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const; /** 수 칸에 수가 아닌 글 — 계산은 그 칸을 빼고 하되 알림(엑셀처럼 무시 · 오류 목록에는 이 까닭으로). */ export const NOT_A_NUMBER = "수가 아님"; export const SKIPPED_PREFIX = "수가 아닌 칸"; /** 값은 그대로 두고 알리기만 하는 까닭인가 — 화면이 글을 「#오류」 로 바꾸지 않게. */ export const isNotice = (why: string): boolean => why === NOT_A_NUMBER || why.startsWith(SKIPPED_PREFIX); export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글"; /** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */ export function totalFormula(total: SheetTotal, col: SheetColumn): string | null { if (!isNumberColumn(col)) return null; const text = typeof total.식 === "string" ? total.식 : (total.식[col.id] ?? total.식["*"]); if (!text || !text.trim()) return null; return text.trim().toUpperCase() === "SUM" ? `SUM([${col.id}])` : text; } /** 칸 값 → 수 · 글. 빈 칸은 null. */ function inputValue(raw: unknown): Value | null { if (raw === null || raw === undefined || raw === "") return null; if (typeof raw === "number") return toFrac(raw); const text = String(raw).trim(); if (text === "") return null; try { return toFrac(text); } catch { return text; } } function applyRound(value: Value, rounding: SheetRounding | null | undefined): Value { if (!rounding || typeof value !== "object") return value; return roundAt(value, rounding.자리 ?? 0, ROUND_MODE[rounding.방법] ?? "round"); } const show = (value: Value): string => typeof value === "object" ? fracToString(value) : typeof value === "boolean" ? value ? "1" : "0" : value; class Failed { constructor(readonly message: string) {} } export function recalcSheet(doc: SheetDoc): SheetResult { const cols = new Map(doc.열.map((c) => [c.id, c])); const rows = new Map(doc.줄.map((r) => [r.id, r])); const totals = new Map((doc.합계줄 ?? []).map((t) => [t.id, t])); const parsed = new Map(); const memo = new Map(); const visiting = new Set(); const result: SheetResult = { 계산: {}, 합계: {}, 오류: [] }; const parse = (text: string): Node => { let node = parsed.get(text); if (!node) { try { node = parseFormula(text); } catch (error) { node = new Failed(error instanceof Error ? error.message : String(error)); } parsed.set(text, node); } if (node instanceof Failed) throw new FormulaError(node.message); return node; }; const formulaOf = (rowId: string, col: SheetColumn): string | null => { const row = rows.get(rowId); if (row) return row.식?.[col.id] ?? col.식 ?? null; return totalFormula(totals.get(rowId)!, col); }; /** 한 칸 — 식 칸이면 풀어 끝수까지 · 입력 칸이면 값. 빈 칸 null. */ const cell = (rowId: string, colId: string): Value | null => { const col = cols.get(colId); if (!col) throw new FormulaError(`없는 열: ${colId}`); if (!rows.has(rowId) && !totals.has(rowId)) throw new FormulaError(`없는 줄: ${rowId}`); const key = `${rowId}\u0000${colId}`; if (memo.has(key)) { const hit = memo.get(key)!; if (hit instanceof Failed) throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`); return hit; } const formula = formulaOf(rowId, col); if (!formula) { const value = rows.has(rowId) ? inputValue(rows.get(rowId)!.값[colId]) : null; memo.set(key, value); return value; } if (visiting.has(key)) throw new FormulaError("돌고 도는 참조"); visiting.add(key); try { const value = applyRound(evaluate(parse(formula), scopeFor(rowId)), col.끝수); memo.set(key, value); return value; } catch (error) { const message = error instanceof Error ? error.message : String(error); memo.set(key, new Failed(message)); result.오류.push({ 줄: rowId, 열: colId, 까닭: message } satisfies SheetError); throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`); } finally { visiting.delete(key); } }; const scopeFor = (rowId: string): Scope => ({ cell: (col, row) => cell(row ?? rowId, col) ?? ZERO, column: (col) => { const out: Frac[] = []; for (const id of rows.keys()) { const value = cell(id, col); if (typeof value === "object" && value !== null) out.push(value); } return out; }, variable: (name) => { const raw = doc.변수?.[name]; if (raw === undefined) throw new FormulaError(`없는 변수: $${name}`); return inputValue(raw) ?? ZERO; }, }); const solve = (rowId: string, into: Record>): void => { for (const col of doc.열) { if (!formulaOf(rowId, col)) continue; try { const value = cell(rowId, col.id); (into[rowId] ??= {})[col.id] = value === null ? "" : show(value); } catch { // 까닭은 `오류` 에 이미 적힘 } } }; for (const id of rows.keys()) solve(id, result.계산); for (const id of totals.keys()) solve(id, result.합계); // 수 칸에 든 수 아닌 글 — 그 칸 + 그 열을 더하는 합계 칸에 알림 const badCount = new Map(); for (const row of doc.줄) { for (const col of doc.열) { if (!isNumberColumn(col) || formulaOf(row.id, col)) continue; if (typeof inputValue(row.값[col.id]) !== "string") continue; result.오류.push({ 줄: row.id, 열: col.id, 까닭: NOT_A_NUMBER }); badCount.set(col.id, (badCount.get(col.id) ?? 0) + 1); } } for (const total of totals.values()) { for (const col of doc.열) { const n = badCount.get(col.id); const formula = n && totalFormula(total, col); if (!formula || !formula.includes(`[${col.id}`)) continue; const 까닭 = `${SKIPPED_PREFIX} ${n} 개는 빼고 더함`; result.오류.push({ 줄: total.id, 열: col.id, 까닭 }); } } return result; } /** 화면 표시용 — 십진 글에 천 단위 쉼표. */ export function groupDigits(text: string): string { const match = /^(-?)(\d+)(\.\d+)?$/.exec(text); if (!match) return text; return `${match[1]}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${match[3] ?? ""}`; }