/* ============================================================================= * 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; vars?: Record; amount?: string | number | null; unit?: string; rounding?: FormulaRounding | null; source?: string; } /** LOOKUP 표 — `keys` 는 **오름차순**이어야 함(근사 일치의 전제). */ export interface LookupTable { keys: (number | string)[]; columns: Record; } export interface FormulaSheet { rows: FormulaRow[]; /** 장 전체에 걸리는 제원 — 줄의 `vars` 가 같은 이름이면 줄이 이김. */ vars?: Record; tables?: Record; } 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; tables: Record; } 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([ "floor", "trunc", "round", "ceil_away", "ceil", "none", "round_half_even", ]); /** `{제원}` 을 제원 값으로 — 모르는 이름은 그대로 두지 않고 오류(조인 키가 흔들림). */ function fillSpec(spec: string, vars: Record): 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(); 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(); 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; }