- 풀이기에 명세 13장 보강분: when(안 선 줄, 가리키면 오류) · destination 필수(기본값 없음) ·
spec 의 {제원} 채움 · 반올림 trunc(ROUNDDOWN)·ceil_away(ROUNDUP)
- resources/library_structure/masonry_wet.json — 줄 15 · 뒷길이×돌종류 표 · 제원 vars ·
물구멍 2.5㎡ 실무 관측은 대안 후보로
- 양식 제원 채우기 B08_Quantity_Engine_StructureTemplate.py(계산 없음)
- 대조 시험 121건: 높이·뒷길이·돌종류·기초·사용자 칸·when 갈래에서 전개와 한 줄도 안 갈림.
알려진 차이 하나(실무 관행 계수가 돌종류를 지우는 전개 결함)는 시험에 드러냄
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
/* =============================================================================
|
||
* 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` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
|
||
* ========================================================================== */
|
||
|
||
/**
|
||
* 반올림 갈래 — 엑셀 대응(명세 13장 대응표): `INT`=floor · `ROUNDDOWN`=trunc · `ROUNDUP`=ceil_away ·
|
||
* `ROUND`=round(사사오입). ⚠ 양수에서는 floor=trunc · ceil=ceil_away 라 **음수에서만 갈림**.
|
||
*/
|
||
export type RoundingMode =
|
||
"floor" | "trunc" | "round" | "ceil_away" | "ceil" | "none" | "round_half_even";
|
||
|
||
export interface FormulaRounding {
|
||
mode: RoundingMode;
|
||
digits: number;
|
||
}
|
||
|
||
/** 갈 곳 — 이중계상 경계를 줄 단위로(명세 13장 Ⓑ). ⛔ 빠지면 오류, 기본값 없음. */
|
||
export const DESTINATIONS = [
|
||
"earthwork",
|
||
"material",
|
||
"unit_price",
|
||
"reference",
|
||
"haul_deduction",
|
||
] as const;
|
||
|
||
/** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */
|
||
export interface FormulaRow {
|
||
seq: number;
|
||
name: string;
|
||
/** 규격. `{제원이름}` 은 그 제원 값으로 바뀜 — 이름은 고정하고 종류는 여기로(명세 13장 Ⓒ). */
|
||
spec?: string;
|
||
/** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */
|
||
formula?: string | null;
|
||
formula_text?: string;
|
||
/** 줄이 서느냐 — 거짓이면 그 줄은 **안 섬**(0 이 아님, 명세 13장 Ⓐ). */
|
||
when?: string | null;
|
||
destination?: 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;
|
||
/** `{제원}` 을 채운 규격. */
|
||
spec: string;
|
||
/** 반올림 뒤 값(십진 문자열). 오류·안 섬이면 `null`. */
|
||
amount: string | null;
|
||
/** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */
|
||
raw: string | null;
|
||
/** `when` 이 거짓이라 안 선 줄 — 화면은 「안 섬」과 `reason` 을 보임. */
|
||
skipped: boolean;
|
||
reason: 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;
|
||
if (mode === "trunc") return q; // 엑셀 ROUNDDOWN — 0 쪽
|
||
if (mode === "ceil_away") return negative ? q - 1n : q + 1n; // 엑셀 ROUNDUP — 0 에서 먼 쪽
|
||
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;
|
||
|
||
/** 안 선 줄·오류 난 줄을 가리키는 이름 — 쓰이는 순간 그 까닭으로 막음. */
|
||
class Blocked {
|
||
constructor(readonly message: string) {}
|
||
}
|
||
|
||
interface Scope {
|
||
names: Map<string, Value | Blocked>;
|
||
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}`);
|
||
// 안 선 줄·오류 난 줄은 **쓰일 때** 막음 — `when` 이 그 이름을 안 쓰면 줄은 그대로 판정됨.
|
||
if (value instanceof Blocked) throw new FormulaError(value.message);
|
||
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}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const ROUNDING_MODES = new Set<string>([
|
||
"floor",
|
||
"trunc",
|
||
"round",
|
||
"ceil_away",
|
||
"ceil",
|
||
"none",
|
||
"round_half_even",
|
||
]);
|
||
|
||
/** `{제원}` 을 제원 값으로 — 모르는 이름은 그대로 두지 않고 오류(조인 키가 흔들림). */
|
||
function fillSpec(spec: string, vars: Record<string, number | string>): string {
|
||
return spec.replace(/\{([^{}]+)\}/g, (_, key: string) => {
|
||
const value = vars[key];
|
||
if (value === undefined) throw new FormulaError(`규격의 모르는 제원: ${key}`);
|
||
return typeof value === "number" ? fracToString(toFrac(value)) : value;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 —
|
||
* 오류 난 줄·안 선 줄을 가리키는 줄은 막힘(0 으로 때우지 않음, 명세 13장 Ⓐ).
|
||
*/
|
||
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; skipped: boolean }>();
|
||
const results: FormulaRowResult[] = [];
|
||
|
||
for (const row of rows) {
|
||
const result: FormulaRowResult = {
|
||
seq: row.seq,
|
||
name: row.name,
|
||
spec: row.spec ?? "",
|
||
amount: null,
|
||
raw: null,
|
||
skipped: false,
|
||
reason: null,
|
||
error: null,
|
||
};
|
||
try {
|
||
if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`);
|
||
// ⛔ 갈 곳 기본값 없음 — 빠진 줄이 자재총괄에서 조용히 사라진 결함이 실재함(명세 13장 Ⓑ).
|
||
if (!row.destination) throw new FormulaError("갈 곳(destination)이 없음");
|
||
if (!(DESTINATIONS as readonly string[]).includes(row.destination)) {
|
||
throw new FormulaError(`모르는 갈 곳: ${row.destination}`);
|
||
}
|
||
if (row.rounding && !ROUNDING_MODES.has(row.rounding.mode)) {
|
||
throw new FormulaError(`모르는 반올림: ${row.rounding.mode}`);
|
||
}
|
||
const vars = { ...(sheet.vars ?? {}), ...(row.vars ?? {}) };
|
||
result.spec = fillSpec(row.spec ?? "", vars);
|
||
const names = new Map<string, Value | Blocked>();
|
||
for (const [key, value] of Object.entries(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}`);
|
||
names.set(
|
||
key,
|
||
target.value ??
|
||
new Blocked(`앞 줄 ${seq}(${target.name}) ${target.skipped ? "안 섬" : "오류"}`),
|
||
);
|
||
}
|
||
const scope: Scope = { names, tables: sheet.tables ?? {} };
|
||
if (row.when && row.when.trim() && !truthy(evaluate(parse(row.when), scope))) {
|
||
result.skipped = true;
|
||
result.reason = `조건이 거짓: ${row.when}`;
|
||
done.set(row.seq, { value: null, name: row.name, skipped: true });
|
||
results.push(result);
|
||
continue;
|
||
}
|
||
let raw: Frac;
|
||
if (row.formula && row.formula.trim()) {
|
||
raw = asFrac(evaluate(parse(row.formula), scope));
|
||
} 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, skipped: false });
|
||
} catch (error) {
|
||
result.error = error instanceof Error ? error.message : String(error);
|
||
done.set(row.seq, { value: null, name: row.name, skipped: false });
|
||
}
|
||
results.push(result);
|
||
}
|
||
return results;
|
||
}
|