Files
Aislo/ui_template/sheet/ui_template_sheet_formula.ts
T
eomsangdonandClaude Opus 5.5 02ba8cbf02 feat(sheet): 표 식 풀이 한 벌 — 화면 · 서버 Node 가 같이 씀 (PLAN 10-2)
- 옛 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
2026-09-25 09:36:54 +09:00

295 lines
10 KiB
TypeScript

/* =============================================================================
* ui_template_sheet_formula.ts
* 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함.
*
* 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN ·
* MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수).
* `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음).
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음.
* 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터.
* ========================================================================== */
import {
add,
cmp,
div,
type Frac,
frac,
FormulaError,
type IntMode,
mul,
parseDecimal,
roundAt,
sub,
toInteger,
ZERO,
} from "./ui_template_sheet_frac";
type Token =
| { kind: "num"; text: string }
| { kind: "str"; text: string }
| { kind: "id"; text: string }
| { kind: "ref"; text: string }
| { kind: "op"; text: string };
export type Node =
| { type: "num"; value: Frac }
| { type: "str"; value: string }
| { type: "ref"; col: string; row: string | null }
| { type: "var"; name: string }
| { type: "unary"; op: string; arg: Node }
| { type: "binary"; op: string; left: Node; right: Node }
| { type: "call"; name: string; args: Node[] };
export type Value = Frac | string | boolean;
/** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */
export interface Scope {
cell(col: string, row: string | null): Value;
column(col: string): Frac[];
variable(name: string): Value;
}
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"];
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
const ROUNDERS: Record<string, IntMode> = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" };
function tokenize(source: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < source.length) {
const ch = source[i];
if (/\s/.test(ch)) {
i += 1;
continue;
}
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
if (number) {
tokens.push({ kind: "num", text: number[0] });
i += number[0].length;
continue;
}
if (ch === "[") {
const end = source.indexOf("]", i + 1);
if (end < 0) throw new FormulaError("「[」가 닫히지 않음");
tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() });
i = end + 1;
continue;
}
if (ch === "'" || ch === '"') {
const end = source.indexOf(ch, i + 1);
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
i = end + 1;
continue;
}
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
if (identifier) {
tokens.push({ kind: "id", text: identifier[0].toUpperCase() });
i += identifier[0].length;
continue;
}
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
tokens.push({ kind: "op", text: op });
i += op.length;
}
return tokens;
}
function refNode(text: string): Node {
if (!text) throw new FormulaError("빈 참조 []");
if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() };
const at = text.indexOf("@");
if (at < 0) return { type: "ref", col: text, row: null };
return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() };
}
/** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */
export function parseFormula(source: string): Node {
const tokens = tokenize(source.trim().replace(/^=/, ""));
let at = 0;
const peek = (): Token | undefined => tokens[at];
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
const expect = (text: string): void => {
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
at += 1;
};
const comparison = (): Node => {
let left = additive();
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
const op = tokens[at++].text;
left = { type: "binary", op, left, right: additive() };
}
return left;
};
const additive = (): Node => {
let left = term();
while (isOp("+") || isOp("-")) {
const op = tokens[at++].text;
left = { type: "binary", op, left, right: term() };
}
return left;
};
const term = (): Node => {
let left = unary();
while (isOp("*") || isOp("/")) {
const op = tokens[at++].text;
left = { type: "binary", op, left, right: unary() };
}
return left;
};
const unary = (): Node => {
if (isOp("-") || isOp("+")) {
const op = tokens[at++].text;
return { type: "unary", op, arg: unary() };
}
return primary();
};
const primary = (): Node => {
const token = peek();
if (!token) throw new FormulaError("식이 중간에 끝남");
at += 1;
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
if (token.kind === "str") return { type: "str", value: token.text };
if (token.kind === "ref") return refNode(token.text);
if (token.kind === "id") {
if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`);
at += 1;
const args: Node[] = [];
if (!isOp(")")) {
args.push(comparison());
while (isOp(",")) {
at += 1;
args.push(comparison());
}
}
expect(")");
return { type: "call", name: token.text, args };
}
if (token.text === "(") {
const inner = comparison();
expect(")");
return inner;
}
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
};
if (!tokens.length) throw new FormulaError("빈 식");
const tree = comparison();
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
return tree;
}
/** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */
export function* refsOf(node: Node): Generator<Node & { type: "ref" | "var" }> {
if (node.type === "ref" || node.type === "var") yield node;
else if (node.type === "unary") yield* refsOf(node.arg);
else if (node.type === "binary") {
yield* refsOf(node.left);
yield* refsOf(node.right);
} else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg);
}
const isFrac = (v: Value): v is Frac => typeof v === "object";
export function asFrac(value: Value): Frac {
if (isFrac(value)) return value;
if (typeof value === "boolean") return frac(value ? 1n : 0n);
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
}
function truthy(value: Value): boolean {
if (typeof value === "boolean") return value;
if (isFrac(value)) return value.n !== 0n;
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
}
function compareValues(a: Value, b: Value): number {
if (typeof a === "string" || typeof b === "string") {
if (typeof a !== "string" || typeof b !== "string") {
throw new FormulaError("글과 수를 견줄 수 없음");
}
return a === b ? 0 : a < b ? -1 : 1;
}
return cmp(asFrac(a), asFrac(b));
}
function digitsOf(node: Node | undefined, scope: Scope): number {
if (!node) return 0;
const value = asFrac(evaluate(node, scope));
if (value.d !== 1n) throw new FormulaError("자리 수는 정수");
return Number(value.n);
}
export function evaluate(node: Node, scope: Scope): Value {
switch (node.type) {
case "num":
case "str":
return node.value;
case "ref":
return scope.cell(node.col, node.row);
case "var":
return scope.variable(node.name);
case "unary": {
const value = asFrac(evaluate(node.arg, scope));
return node.op === "-" ? sub(ZERO, value) : value;
}
case "binary": {
const left = evaluate(node.left, scope);
const right = evaluate(node.right, scope);
if (COMPARISONS.has(node.op)) {
const order = compareValues(left, right);
return {
"=": order === 0,
"<>": order !== 0,
"<": order < 0,
"<=": order <= 0,
">": order > 0,
">=": order >= 0,
}[node.op]!;
}
const a = asFrac(left);
const b = asFrac(right);
if (node.op === "+") return add(a, b);
if (node.op === "-") return sub(a, b);
if (node.op === "*") return mul(a, b);
return div(a, b);
}
case "call": {
const { name, args } = node;
if (name === "IF") {
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
// 고른 갈래만 풂 — 안 고른 갈래의 오류가 칸을 막지 않게
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
}
if (name === "SUM") {
let total = ZERO;
for (const arg of args) {
const values =
arg.type === "ref" && arg.row === null
? scope.column(arg.col)
: [asFrac(evaluate(arg, scope))];
for (const value of values) total = add(total, value);
}
return total;
}
if (name === "INT") {
if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나");
return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor"));
}
if (name in ROUNDERS) {
if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`);
return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]);
}
if (name === "MIN" || name === "MAX") {
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
}
throw new FormulaError(`모르는 함수: ${name}`);
}
}
}