- 옛 B08 식 풀이기(git 8472fc9f)의 분수 · 파서를 ui_template/sheet/ 로 되살림 — 필요한 함수만
- 열 참조 [열id] · 셀 참조 [열id@줄id] · 변수 [$이름] · SUM · INT · ROUND · ROUNDUP · ROUNDDOWN · MIN · MAX · IF
- 한 칸만 다른 식(줄.식) · 합계 줄 열마다 다른 식 · 끝수(반올림 · 올림 · 버림) · 순환은 그 칸만 오류
- 서버 껍데기 common_util_sheet_recalc.py · build:formula 가 새 진입점을 가리킴(깨진 B08 경로 고침)
- 시험 — 실무 울진 2공구 구조물집계표 합계 캐시값 · 파이썬 Decimal 거울 40문서
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_sheet_recalc.ts
|
|
* 표 문서 한 벌 풀이 — 계산 열 · 한 칸만 다른 식(`줄.식`) · 합계 줄(열마다 다른 식) · 변수.
|
|
*
|
|
* 화면(조작 중 즉시)과 서버(`ui_template_sheet_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 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<string, Node | Failed>();
|
|
const memo = new Map<string, Value | null | Failed>();
|
|
const visiting = new Set<string>();
|
|
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<string, Record<string, string>>): 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.합계);
|
|
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] ?? ""}`;
|
|
}
|