diff --git a/.gitignore b/.gitignore index dd87ad9d..0f615447 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ tmp/ config/corridor_node/ # 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06). config/server_calc_node/ +# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13). +config/formula_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/B08_Quantity/B08_Quantity_Engine_Formula.py b/B08_Quantity/B08_Quantity_Engine_Formula.py new file mode 100644 index 00000000..2f184d70 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Formula.py @@ -0,0 +1,28 @@ +"""구조물도 식 풀이 — 서버가 부르는 **껍데기**. 계산은 여기 없음. + +풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 한 벌을 Node 로 돌림(판정 Ⓐ, 2026-09-13 · +명세 13장). 파이썬으로 다시 짜면 반올림이 1원에서 조용히 갈리므로 두 벌을 두지 않음. +B06 `B06_Section_Server_Calc_Prebuild.py` 와 같은 본(`common_util_node_bundle`). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from common_util.common_util_node_bundle import run_bundle_json + +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "config" / "formula_node" / "B08_Quantity_Formula_Node.js" +_NPM_SCRIPT = "build:formula" + + +def evaluate_sheets(sheets: list[dict[str, Any]]) -> list[list[dict[str, Any]]] | None: + """장 여러 벌을 한 번에 풂 — 줄마다 `amount`(반올림 뒤)·`raw`·`error`. + + Node 가 못 돌면 `None` — 값을 지어내지 않고 부르는 쪽이 「못 풂」으로 드러냄. + """ + output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"sheets": sheets}) + if not isinstance(output, dict) or not isinstance(output.get("sheets"), list): + return None + return output["sheets"] diff --git a/B08_Quantity/B08_Quantity_Formula.ts b/B08_Quantity/B08_Quantity_Formula.ts new file mode 100644 index 00000000..e41828be --- /dev/null +++ b/B08_Quantity/B08_Quantity_Formula.ts @@ -0,0 +1,513 @@ +/* ============================================================================= + * 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; + 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; + /** 반올림 뒤 값(십진 문자열). 오류면 `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; + 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}`); + 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(); + 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(); + 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; +} diff --git a/B08_Quantity/B08_Quantity_Formula_Node.ts b/B08_Quantity/B08_Quantity_Formula_Node.ts new file mode 100644 index 00000000..913da0fa --- /dev/null +++ b/B08_Quantity/B08_Quantity_Formula_Node.ts @@ -0,0 +1,25 @@ +/* ============================================================================= + * B08_Quantity_Formula_Node.ts + * 구조물도 식 풀이를 **서버가** 돌리는 진입점 — [저장]·[확정] 때 정본으로 다시 풂. + * + * 풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 그대로(판정 Ⓐ, 2026-09-13) — + * B06 `B06_Section_Server_Calc_Node.ts` 와 같은 본. 여기에는 계산이 없음. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { sheets: FormulaSheet[] } + * 출력 { sheets: FormulaRowResult[][] } — 입력 장 차례 그대로 + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula"; + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { sheets?: FormulaSheet[] }; +const sheets = (input.sheets ?? []).map((sheet) => evaluateSheet(sheet)); +writeFileSync(outputPath, JSON.stringify({ sheets })); diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 4e85515c..3b38affe 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -19,8 +19,8 @@ from typing import Any logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] -# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더. -SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") +# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13). +SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 diff --git a/package.json b/package.json index 7e20859b..2304830f 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", + "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B08_Quantity/B08_Quantity_Formula_Node.ts --outDir ../config/formula_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/resources/tester/test_b08_formula.py b/resources/tester/test_b08_formula.py new file mode 100644 index 00000000..3d0ea07c --- /dev/null +++ b/resources/tester/test_b08_formula.py @@ -0,0 +1,257 @@ +"""구조물도 식 풀이기 — 명세 13장 언어·반올림·참조를 **서버 Node 경로 그대로** 시험 (2026-09-13). + +풀이는 `B08_Quantity_Formula.ts` 한 벌이고(판정 Ⓐ), 서버는 그것을 Node 로 돌림. +그래서 이 시험도 파이썬 껍데기(`evaluate_sheets`) → Node 번들 길로 부름 — 화면과 서버가 +같은 코드를 쓰는지까지 한 번에 잼. + +⭐ 기준값 하나는 **실무 엑셀 캐시값** — 소광리 `07-구조도-소광리.xlsx` 「돌기슭막이(45)찰-고1.0」 + U20~U31 11줄(2026-09-13 openpyxl 로 읽음). 식을 셀 그대로 옮겨 같은 값이 나오는지 봄. +⚠ 부동소수로 풀면 틀리는 자리를 일부러 넣음 — `ROUND(0.95*0.3,2)`(부동소수 0.28499… → 0.28)· + `0.29*100` 버림(28.999… → 28). 분수로 풀어야 엑셀·실무 값과 맞음. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402 + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]: + result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}]) + assert result is not None, "Node 풀이가 안 돌았다" + return result[0] + + +def _one(formula: str, rounding: dict | None = None, **vars) -> dict: + return _solve([{"seq": 1, "name": "x", "formula": formula, "rounding": rounding}], vars)[0] + + +def _amount(formula: str, rounding: dict | None = None, **vars) -> str: + row = _one(formula, rounding, **vars) + assert row["error"] is None, row + return row["amount"] + + +def test_적힌_차례대로_풂() -> None: + """명세 13장 지킬 것 ① — 접지 않음. 적힌 상수는 적힌 대로.""" + assert _amount("267360*5/24") == "55700" + assert _amount("267360*0.20833") == "55699.1088" + + +def test_우선순위는_엑셀과_같다() -> None: + assert _amount("2+3*4") == "14" + assert _amount("10-2-3") == "5" + assert _amount("8/2/2") == "2" + assert _amount("2^3^2") == "64" # 엑셀은 왼쪽부터 + assert _amount("-2^2") == "4" # 엑셀은 부호가 ^ 보다 먼저 + assert _amount("(1+2)*3") == "9" + + +def test_부동소수_함정을_안_밟는다() -> None: + assert _amount("0.29*100", {"mode": "floor", "digits": 0}) == "29" + assert _amount("1.15*100", {"mode": "floor", "digits": 0}) == "115" + assert _amount("0.95*0.3", {"mode": "round", "digits": 2}) == "0.29" + + +def test_반올림_갈래() -> None: + assert _amount("2.675", {"mode": "round", "digits": 2}) == "2.68" # 사사오입 + assert _amount("-2.5", {"mode": "round", "digits": 0}) == "-3" # 0 에서 먼 쪽(엑셀 ROUND) + assert _amount("2.665", {"mode": "round_half_even", "digits": 2}) == "2.66" + assert _amount("2.675", {"mode": "round_half_even", "digits": 2}) == "2.68" + assert _amount("0.121", {"mode": "ceil", "digits": 2}) == "0.13" + assert _amount("2.61*0.15", {"mode": "floor", "digits": 2}) == "0.39" + assert _amount("1234", {"mode": "round", "digits": -2}) == "1200" + row = _one("1/3", {"mode": "none", "digits": 0}) + assert row["amount"] == "0." + "3" * 30 + + +def test_함수() -> None: + assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088" + assert _amount("SQRT(2.25)") == "1.5" + assert _amount("SUM(1,2,3.5)") == "6.5" + assert _amount("MIN(3,1,2)") == "1" + assert _amount("MAX(3,1,2)") == "3" + + +def test_IF_와_비교() -> None: + 식 = "IF(L3=35,0.12,IF(L3=45,0.15,0.18))" + assert _amount(식, L3=45) == "0.15" + assert _amount(식, L3=55) == "0.18" + assert _amount("IF(돌종류='야면석',0.15,0.2)", 돌종류="야면석") == "0.15" + assert _amount("IF(L3<>45,1,2)", L3=45) == "2" + assert _amount("IF(L3>=45,1,2)", L3=45) == "1" + # 안 고른 갈래는 풀지 않음 — 0 으로 나누는 갈래가 줄을 막지 않게. + assert _amount("IF(1=1,5,1/0)") == "5" + + +뒷길이표 = { + "뒷길이표": { + "keys": [35, 45, 55, 60], + "columns": {"고임돌": [0.12, 0.15, 0.18, 0.2], "빈칸": [0.1, None, 0.2, 0.3]}, + } +} + + +def _lookup(formula: str, **vars) -> dict: + return _solve([{"seq": 1, "name": "x", "formula": formula}], vars, 뒷길이표)[0] + + +def test_LOOKUP_은_근사_일치가_기본() -> None: + """엑셀 `VLOOKUP(…,1)` — 키 이하 중 가장 큰 줄(실무 13건 전부 근사).""" + assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=45)["amount"] == "0.15" + assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=50)["amount"] == "0.15" + assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=100)["amount"] == "0.2" + assert "못 찾음" in _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=30)["error"] + + +def test_LOOKUP_EXACT_와_빈칸() -> None: + assert _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=55)["amount"] == "0.18" + assert "못 찾음" in _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=50)["error"] + # 원문 「-」 칸은 값이 아니라 오류 — 0 으로 때우지 않음. + assert "비어 있음" in _lookup("LOOKUP(뒷길이표, L3, '빈칸')", L3=45)["error"] + assert "없는 열" in _lookup("LOOKUP(뒷길이표, L3, '없는열')", L3=45)["error"] + + +def test_표_키가_오름차순이_아니면_오류() -> None: + tables = {"표": {"keys": [45, 35], "columns": {"a": [1, 2]}}} + row = _solve([{"seq": 1, "name": "x", "formula": "LOOKUP(표, 40, 'a')"}], {}, tables)[0] + assert "오름차순" in row["error"] + + +def test_앞_줄만_참조한다() -> None: + rows = [ + {"seq": 1, "name": "면적", "formula": "H*L"}, + {"seq": 2, "name": "고임돌", "formula": "AREA*0.15", "refs": {"AREA": 1}}, + {"seq": 3, "name": "뒷줄", "formula": "X*2", "refs": {"X": 4}}, + {"seq": 4, "name": "자기", "formula": "S*2", "refs": {"S": 4}}, + ] + result = _solve(rows, {"H": 2.5, "L": 10}) + assert [row["amount"] for row in result[:2]] == ["25", "3.75"] + assert "앞 줄만" in result[2]["error"] + assert "앞 줄만" in result[3]["error"] + + +def test_오류_난_줄을_가리키면_막힌다() -> None: + rows = [ + {"seq": 1, "name": "나눔", "formula": "1/0"}, + {"seq": 2, "name": "뒤", "formula": "A+1", "refs": {"A": 1}}, + ] + first, second = _solve(rows) + assert "0 으로 나눔" in first["error"] and first["amount"] is None + assert "앞 줄 1" in second["error"] and second["amount"] is None + + +def test_참조는_반올림_뒤_값을_받는다() -> None: + """엑셀 셀 참조와 같음 — `INT(…)` 가 든 셀을 가리키면 버린 값이 옴.""" + rows = [ + {"seq": 1, "name": "a", "formula": "0.456", "rounding": {"mode": "floor", "digits": 2}}, + {"seq": 2, "name": "b", "formula": "A*100", "refs": {"A": 1}}, + ] + result = _solve(rows) + assert result[0]["raw"] == "0.456" and result[0]["amount"] == "0.45" + assert result[1]["amount"] == "45" + + +def test_고정형과_줄_제원() -> None: + rows = [ + {"seq": 1, "name": "박힌값", "amount": "2.088"}, + {"seq": 2, "name": "빈", "formula": ""}, + {"seq": 3, "name": "줄이이김", "formula": "K", "vars": {"K": 7}}, + ] + result = _solve(rows, {"K": 3}) + assert result[0]["amount"] == "2.088" + assert "식도 값도 없음" in result[1]["error"] + assert result[2]["amount"] == "7" + + +def test_모르는_이름_함수_글자는_오류() -> None: + assert "모르는 이름" in _one("constructor")["error"] + assert "모르는 함수" in _one("EVAL(1)")["error"] + assert "읽을 수 없는 글자" in _one("1;2")["error"] + assert "따옴표" in _one("'abc")["error"] + assert ( + "같은 차례" + in _solve( + [{"seq": 1, "name": "a", "formula": "1"}, {"seq": 1, "name": "b", "formula": "2"}] + )[1]["error"] + ) + + +def test_실무_찰쌓기_탭을_셀_그대로_재현() -> None: + """소광리 「돌기슭막이(45)찰-고1.0」 — H=1 · 뒷길이 45 · 1:0.3 · 윗폭 300㎜. + + 셀 식을 이름만 바꿔 옮김(C5→H, D3→L3, J11→N, L4→TOP_MM). 엑셀 캐시값과 같아야 함. + """ + vars = {"H": 1, "L3": 45, "N": 0.3, "TOP_MM": 300} + rows = [ + {"seq": 1, "name": "면적", "formula": "H*1*1.044"}, + {"seq": 2, "name": "중량", "formula": "A*0.91", "refs": {"A": 1}}, + { + "seq": 3, + "name": "체적", + "formula": "((L3*10+TOP_MM)/1000+(L3*10+H*1000*N)/1000)/2*A", + "refs": {"A": 1}, + }, + { + "seq": 4, + "name": "고임돌", + "formula": "A*IF(L3=35,0.12,IF(L3=45,0.15,IF(L3=55,0.18,0)))", + "refs": {"A": 1}, + "rounding": {"mode": "floor", "digits": 2}, + }, + {"seq": 5, "name": "석적", "formula": "H*L3/100*0.77"}, + {"seq": 6, "name": "막자갈", "formula": "(TOP_MM/1000+H*1000*N/1000)/2*H"}, + { + "seq": 7, + "name": "채움콘크리트", + "formula": "A*IF(L3=35,0.16,IF(L3=45,0.2,IF(L3=55,0.25,0)))", + "refs": {"A": 1}, + "rounding": {"mode": "floor", "digits": 2}, + }, + {"seq": 8, "name": "모르터", "formula": "A*0.009", "refs": {"A": 1}}, + {"seq": 9, "name": "물구멍", "formula": "A*1/2.5*0.5", "refs": {"A": 1}}, + { + "seq": 10, + "name": "터파기", + "formula": "((H*1000*N+L3*10)/1000+0.2)*0.3*1", + "rounding": {"mode": "round", "digits": 2}, + }, + { + "seq": 11, + "name": "되메우기", + "formula": "(0.2*0.3+(TOP_MM/1000+0.2)*(TOP_MM/1000+0.2)/2)*1", + }, + {"seq": 12, "name": "잔토정리", "formula": "T-B", "refs": {"T": 10, "B": 11}}, + ] + 엑셀 = { + "면적": 1.044, + "중량": 0.9500400000000001, + "체적": 0.783, + "고임돌": 0.15, + "석적": 0.34650000000000003, + "막자갈": 0.3, + "채움콘크리트": 0.2, + "모르터": 0.009396, + "물구멍": 0.2088, + "터파기": 0.29, + "되메우기": 0.185, + "잔토정리": 0.10499999999999998, + } + result = {row["name"]: row for row in _solve(rows, vars)} + for name, cached in 엑셀.items(): + assert result[name]["error"] is None, result[name] + # 엑셀 캐시는 부동소수 끝자리가 흔들림 — 유효 12자리까지 같으면 같은 값. + assert float(result[name]["amount"]) == pytest.approx(cached, rel=1e-12), name + # 그러나 우리 값은 **흔들림 없는 십진수**다. + assert result["잔토정리"]["amount"] == "0.105" + assert result["석적"]["amount"] == "0.3465"