Files
Aislo/B08_Quantity/B08_Quantity_Formula.ts
T
eomsangdonandClaude Opus 5 197728d6c9 feat(b08): 구조물도 식 풀이기 — TS 한 벌을 화면·서버 Node 가 같이 씀
- 명세 13장 식 언어(사칙·^·비교·SQRT·MIN·MAX·SUM·IF·LOOKUP 근사/EXACT)를 직접 짠 파서로 풂
- 수는 BigInt 분수로 들고 적힌 차례대로 풂 — 부동소수 버림·반올림 1 틀어짐 막음
- 줄마다 반올림 floor·round(사사오입)·ceil·none·round_half_even, refs 는 앞 줄만
- 서버는 build:formula 번들을 Node 로 부름(B06 Server_Calc 본), 파이썬은 껍데기뿐
- 시험 15건 — 실무 찰쌓기 탭 12줄 엑셀 캐시값 재현 포함

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 16:46:54 +09:00

514 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_Formula.ts
* 구조물도 **식 칸 풀이기** — 명세 13장(`docs/raw/verification/2026-09-13_축C_명세.md`) 한 벌.
*
* 화면(조작 중 즉시)과 서버(`B08_Quantity_Formula_Node.ts` — [저장]·[확정] 재계산)가
* **이 파일 하나**를 같이 씀(판정 Ⓐ, 2026-09-13). 두 벌로 짜면 반올림이 1원에서 조용히 갈림.
*
* ⚠ **수는 BigInt 분수**로 듦 — 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐.
* 실무 엑셀이 `INT(x*100)/100` 으로 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음.
* ⚠ **적힌 차례대로 풂** — 식을 미리 접거나 바꿔 쓰지 않음(명세 13장 지킬 것 ①).
* `267360*5/24` 는 55700, `267360*0.20833` 은 적힌 상수대로 55699.1088 이 남.
* ⚠ 무리수만 끊어 분수로 되돌림 — SQRT 는 소수 30자리 버림, 소수 거듭제곱은 유효 17자리.
* ⚠ `refs` 는 **앞 줄만** — 뒷줄·자기 줄을 가리키면 오류(순환이 원리적으로 불가).
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
* ========================================================================== */
export type RoundingMode = "floor" | "round" | "ceil" | "none" | "round_half_even";
export interface FormulaRounding {
mode: RoundingMode;
digits: number;
}
/** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */
export interface FormulaRow {
seq: number;
name: string;
spec?: string;
/** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */
formula?: string | null;
formula_text?: string;
refs?: Record<string, number>;
vars?: Record<string, number | string>;
amount?: string | number | null;
unit?: string;
rounding?: FormulaRounding | null;
source?: string;
}
/** LOOKUP 표 — `keys` 는 **오름차순**이어야 함(근사 일치의 전제). */
export interface LookupTable {
keys: (number | string)[];
columns: Record<string, (number | string | null)[]>;
}
export interface FormulaSheet {
rows: FormulaRow[];
/** 장 전체에 걸리는 제원 — 줄의 `vars` 가 같은 이름이면 줄이 이김. */
vars?: Record<string, number | string>;
tables?: Record<string, LookupTable>;
}
export interface FormulaRowResult {
seq: number;
name: string;
/** 반올림 뒤 값(십진 문자열). 오류면 `null`. */
amount: string | null;
/** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */
raw: string | null;
error: string | null;
}
/* ---------------------------------------------------------------- 분수 */
const DIGITS = 30;
const TEN = 10n;
interface Frac {
n: bigint;
d: bigint;
}
function abs(x: bigint): bigint {
return x < 0n ? -x : x;
}
function gcd(a: bigint, b: bigint): bigint {
a = abs(a);
b = abs(b);
while (b) [a, b] = [b, a % b];
return a || 1n;
}
function frac(n: bigint, d = 1n): Frac {
if (d === 0n) throw new FormulaError("0 으로 나눔");
if (d < 0n) [n, d] = [-n, -d];
const g = gcd(n, d);
return { n: n / g, d: d / g };
}
const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
const div = (a: Frac, b: Frac): Frac => {
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
return frac(a.n * b.d, a.d * b.n);
};
const cmp = (a: Frac, b: Frac): number => {
const left = a.n * b.d;
const right = b.n * a.d;
return left === right ? 0 : left < right ? -1 : 1;
};
/** 십진 문자열(`-12.5`, `3`, `1e-3`)을 분수로. */
function parseDecimal(text: string): Frac {
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.trim());
if (!match || (match[2] === "" && (match[3] ?? "") === "")) {
throw new FormulaError(`수로 읽지 못함: ${text}`);
}
const [, sign, whole, fraction = "", exponent = "0"] = match;
let n = BigInt((whole || "0") + fraction);
let d = TEN ** BigInt(fraction.length);
const e = Number(exponent);
if (e > 0) n *= TEN ** BigInt(e);
if (e < 0) d *= TEN ** BigInt(-e);
return frac(sign === "-" ? -n : n, d);
}
function toFrac(value: number | string): Frac {
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
// ⚠ 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음.
return parseDecimal(String(value));
}
return parseDecimal(value);
}
/** 정수로 떨굼 — `mode` 는 반올림 칸과 같은 낱말. */
function toInteger(x: Frac, mode: RoundingMode): bigint {
const q = x.n / x.d; // 0 쪽으로 자름
const r = x.n % x.d;
if (r === 0n) return q;
const negative = x.n < 0n;
if (mode === "floor") return negative ? q - 1n : q;
if (mode === "ceil") return negative ? q : q + 1n;
const twice = abs(r) * 2n;
if (twice === x.d && mode === "round_half_even")
return q % 2n === 0n ? q : negative ? q - 1n : q + 1n;
// 사사오입 — 엑셀 `ROUND` 와 같이 0 에서 먼 쪽(명세 13장 ⭐).
if (twice >= x.d) return negative ? q - 1n : q + 1n;
return q;
}
export function applyRounding(x: Frac, rounding?: FormulaRounding | null): Frac {
if (!rounding || rounding.mode === "none") return x;
const digits = Math.trunc(rounding.digits ?? 0);
const scale = digits >= 0 ? frac(TEN ** BigInt(digits)) : frac(1n, TEN ** BigInt(-digits));
return div(frac(toInteger(mul(x, scale), rounding.mode)), scale);
}
/** 분수를 십진 문자열로 — 끝나는 소수는 그대로, 안 끝나면 30자리에서 사사오입. */
export function fracToString(x: Frac): string {
const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round");
const negative = scaled < 0n;
const digits = abs(scaled)
.toString()
.padStart(DIGITS + 1, "0");
const whole = digits.slice(0, -DIGITS);
const fraction = digits.slice(-DIGITS).replace(/0+$/, "");
const body = fraction ? `${whole}.${fraction}` : whole;
return negative && body !== "0" ? `-${body}` : body;
}
function sqrt(x: Frac): Frac {
if (x.n < 0n) throw new FormulaError("음수의 SQRT");
const scale = TEN ** BigInt(DIGITS);
// √(n/d) = √(n·d) / d — 소수 30자리까지 정수 제곱근으로.
const target = x.n * x.d * scale * scale;
if (target === 0n) return frac(0n);
// 정수 제곱근(버림) — 참값보다 큰 2 의 거듭제곱에서 뉴턴으로 내려옴(Number 를 안 거침).
let root = 1n << BigInt(Math.ceil(target.toString(2).length / 2));
for (;;) {
const next = (root + target / root) >> 1n;
if (next >= root) break;
root = next;
}
return frac(root, x.d * scale);
}
function power(base: Frac, exponent: Frac): Frac {
if (exponent.d === 1n) {
const e = exponent.n;
if (e === 0n) return frac(1n);
const magnitude = abs(e);
const raised = frac(base.n ** magnitude, base.d ** magnitude);
return e > 0n ? raised : div(frac(1n), raised);
}
// 소수 지수는 무리수 — 유효 17자리 근사(드문 자리: 실무 53탭에 0회).
const value = Math.pow(Number(base.n) / Number(base.d), Number(exponent.n) / Number(exponent.d));
if (!Number.isFinite(value)) throw new FormulaError("거듭제곱 값이 수가 아님");
return parseDecimal(value.toPrecision(17));
}
/* ---------------------------------------------------------------- 파서 */
class FormulaError extends Error {}
type Token =
| { kind: "num"; text: string }
| { kind: "str"; text: string }
| { kind: "id"; text: string }
| { kind: "op"; text: string };
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "^", "(", ")", ",", "=", "<", ">"];
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 === "'" || 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] });
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;
}
type Node =
| { type: "num"; value: Frac }
| { type: "str"; value: string }
| { type: "name"; name: string }
| { type: "unary"; op: string; arg: Node }
| { type: "binary"; op: string; left: Node; right: Node }
| { type: "call"; name: string; args: Node[] };
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
/** 우선순위는 엑셀과 같음 — 부호 > `^` > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터. */
function parse(source: string): Node {
const tokens = tokenize(source);
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 = exponent();
while (isOp("*") || isOp("/")) {
const op = tokens[at++].text;
left = { type: "binary", op, left, right: exponent() };
}
return left;
};
const exponent = (): Node => {
let left = unary();
while (isOp("^")) {
at += 1;
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 === "id") {
if (!isOp("(")) return { type: "name", name: token.text };
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}`);
};
const tree = comparison();
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
return tree;
}
/* ---------------------------------------------------------------- 풀이 */
type Value = Frac | string | boolean;
interface Scope {
names: Map<string, Value>;
tables: Record<string, LookupTable>;
}
const isFrac = (v: Value): v is Frac => typeof v === "object";
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 lookup(scope: Scope, args: Node[], exact: boolean): Value {
if (args.length !== 3) throw new FormulaError("LOOKUP 은 인자가 셋(표, 키, 열)");
const [tableNode, keyNode, columnNode] = args;
const tableName =
tableNode.type === "name" || tableNode.type === "str"
? tableNode.type === "name"
? tableNode.name
: tableNode.value
: null;
const table = tableName ? scope.tables[tableName] : undefined;
if (!table) throw new FormulaError(`없는 표: ${tableName ?? "?"}`);
const column = evaluate(columnNode, scope);
const cells = typeof column === "string" ? table.columns[column] : undefined;
if (!cells) throw new FormulaError(`표 ${tableName} 에 없는 열: ${String(column)}`);
const key = evaluate(keyNode, scope);
const keys = table.keys.map((item) => (typeof item === "number" ? toFrac(item) : item));
let hit = -1;
for (let i = 0; i < keys.length; i += 1) {
if (i > 0 && compareValues(keys[i - 1], keys[i]) >= 0) {
throw new FormulaError(`표 ${tableName} 의 키가 오름차순이 아님`);
}
const order = compareValues(keys[i], key);
if (order === 0) hit = i;
// 근사 일치 — 키 이하 중 가장 큰 줄(엑셀 `VLOOKUP(…,1)`).
else if (order < 0 && !exact) hit = i;
}
if (hit < 0) throw new FormulaError(`표 ${tableName} 에서 키 ${String(key)} 를 못 찾음`);
const cell = cells[hit];
if (cell === null || cell === undefined || cell === "") {
throw new FormulaError(`표 ${tableName} 의 칸이 비어 있음(원문 「-」)`);
}
return typeof cell === "number" ? toFrac(cell) : cell;
}
function evaluate(node: Node, scope: Scope): Value {
switch (node.type) {
case "num":
case "str":
return node.value;
case "name": {
const value = scope.names.get(node.name);
if (value === undefined) throw new FormulaError(`모르는 이름: ${node.name}`);
return value;
}
case "unary": {
const value = asFrac(evaluate(node.arg, scope));
return node.op === "-" ? frac(-value.n, value.d) : 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] as boolean;
}
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);
if (node.op === "/") return div(a, b);
return power(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 === "LOOKUP") return lookup(scope, args, false);
if (name === "LOOKUP_EXACT") return lookup(scope, args, true);
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
if (name === "SQRT") {
if (values.length !== 1) throw new FormulaError("SQRT 는 인자가 하나");
return sqrt(values[0]);
}
if (name === "SUM") return values.reduce((total, v) => add(total, v), frac(0n));
if (name === "MIN" || name === "MAX") {
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
}
throw new FormulaError(`모르는 함수: ${name}`);
}
}
}
/**
* 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 —
* 오류 난 줄을 가리키는 줄은 「앞 줄 오류」로 막힘(0 으로 때우지 않음).
*/
export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] {
const rows = [...(sheet.rows ?? [])].sort((a, b) => a.seq - b.seq);
const done = new Map<number, { value: Frac | null; name: string }>();
const results: FormulaRowResult[] = [];
for (const row of rows) {
const result: FormulaRowResult = {
seq: row.seq,
name: row.name,
amount: null,
raw: null,
error: null,
};
try {
if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`);
let raw: Frac;
if (row.formula && row.formula.trim()) {
const names = new Map<string, Value>();
for (const [key, value] of Object.entries({ ...(sheet.vars ?? {}), ...(row.vars ?? {}) })) {
names.set(key, typeof value === "number" ? toFrac(value) : value);
}
for (const [key, seq] of Object.entries(row.refs ?? {})) {
if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key}${seq}`);
const target = done.get(seq);
if (!target) throw new FormulaError(`없는 줄: ${key}${seq}`);
if (!target.value) throw new FormulaError(`앞 줄 ${seq}(${target.name}) 오류`);
names.set(key, target.value);
}
raw = asFrac(evaluate(parse(row.formula), { names, tables: sheet.tables ?? {} }));
} else {
// 고정형 — 박힌 값을 그대로(명세 13장 「양식형 ↔ 고정형」).
if (row.amount === null || row.amount === undefined || row.amount === "") {
throw new FormulaError("식도 값도 없음");
}
raw = toFrac(row.amount);
}
const value = applyRounding(raw, row.rounding);
result.raw = fracToString(raw);
result.amount = fracToString(value);
done.set(row.seq, { value, name: row.name });
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
done.set(row.seq, { value: null, name: row.name });
}
results.push(result);
}
return results;
}