Files
Aislo/B08_Quantity/B08_Quantity_Formula.ts
T
eomsangdonandClaude Opus 5 31c2602f4b feat(b08): 찰쌓기 양식 한 벌 + 식 칸 셋(when·destination·spec) — 지금 전개와 값 대조
- 풀이기에 명세 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
2026-09-13 17:02:26 +09:00

589 lines
22 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` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
* ========================================================================== */
/**
* 반올림 갈래 — 엑셀 대응(명세 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;
}