feat(sheet): 표 식 풀이 한 벌 — 화면 · 서버 Node 가 같이 씀 (PLAN 10-2)

- 옛 B08 식 풀이기(git 8472fc9f)의 분수 · 파서를 ui_template/sheet/ 로 되살림 — 필요한 함수만
- 열 참조 [열id] · 셀 참조 [열id@줄id] · 변수 [$이름] · SUM · INT · ROUND · ROUNDUP · ROUNDDOWN · MIN · MAX · IF
- 한 칸만 다른 식(줄.식) · 합계 줄 열마다 다른 식 · 끝수(반올림 · 올림 · 버림) · 순환은 그 칸만 오류
- 서버 껍데기 common_util_sheet_recalc.py · build:formula 가 새 진입점을 가리킴(깨진 B08 경로 고침)
- 시험 — 실무 울진 2공구 구조물집계표 합계 캐시값 · 파이썬 Decimal 거울 40문서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
This commit is contained in:
2026-09-25 09:36:54 +09:00
co-authored by Claude Opus 5.5
parent 0b07251fc2
commit 02ba8cbf02
8 changed files with 931 additions and 1 deletions
+39
View File
@@ -0,0 +1,39 @@
"""표 문서(마스터 템플릿 표 양식) 서버 재계산 — 화면과 같은 TS 를 Node 로 돌리는 껍데기.
CLAUDE.md 5장 ② — 계산은 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌.
여기는 번들을 부르기만 함(`npm run build:formula` → `config/formula_node/`).
[저장] 때 브라우저 값을 받아 적지 않고 이 결과를 정본으로 씀.
결과 = `{계산: {줄: {열: 값}}, 합계: {합계줄: {열: 값}}, 오류: [{줄, 열, 까닭}]}` · 값은 십진 글.
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json
BUNDLE = ROOT / "config" / "formula_node" / "ui_template_sheet_node.js"
NPM_SCRIPT = "build:formula"
SOURCE_DIR = ROOT / "ui_template" / "sheet"
def _stale() -> bool:
"""번들이 없거나 표 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄."""
if not BUNDLE.is_file():
return True
built_at = BUNDLE.stat().st_mtime
return any(p.stat().st_mtime > built_at for p in SOURCE_DIR.glob("*.ts"))
def recalc_sheets(docs: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""표 문서 여럿을 한 번에 — 입력 차례 그대로 결과. 번들 빌드·실행 실패는 None."""
if _stale() and not build_bundle(NPM_SCRIPT):
return None
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"docs": docs})
return None if out is None else out["results"]
def recalc_sheet(doc: dict[str, Any]) -> dict[str, Any] | None:
results = recalc_sheets([doc])
return None if results is None else results[0]
+1 -1
View File
@@ -8,7 +8,7 @@
"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",
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../ui_template/sheet/ui_template_sheet_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",
+195
View File
@@ -0,0 +1,195 @@
"""표 양식 식 풀이 — 서버 Node 길(`common_util_sheet_recalc`) 그대로 시험.
풀이는 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌 · 화면과 서버가 같은 TS 를 씀.
거울 — 파이썬 Decimal 로 따로 푼 답과 Node 답을 무작위 문서 여럿에서 견줌.
⭐ 기준값 하나는 실무 엑셀 캐시값 — 울진 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」
5~14줄 · 16줄 `=SUM(…)` 합계(openpyxl data_only 로 읽음).
"""
from __future__ import annotations
import random
import shutil
import sys
from decimal import ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, getcontext
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from common_util.common_util_sheet_recalc import recalc_sheet, recalc_sheets # noqa: E402
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _doc(cols: list[dict], rows: list[dict], **head) -> dict:
for col in cols:
col.setdefault("머리", [col["id"]])
return {"양식": "시험", "종류": "표", "판": 1, "열": cols, "줄": rows, **head}
def _run(doc: dict) -> dict:
out = recalc_sheet(doc)
assert out is not None, "Node 번들 실행 실패"
return out
# ── 실무 캐시값 ──────────────────────────────────────────────────────────
PRACTICE = { # 측점: {엑셀 열: 값} — 5~14줄
"전구간": {"AC": 10},
"71+13": {"D": 10, "F": 20, "G": 10, "H": 10, "AQ": 22, "AR": 3},
"75+0": {"AF": 1},
"78+7": {"F": 10, "H": 20, "AK": 18, "AL": 2, "AM": 1},
"84+0": {"F": 10, "H": 20, "AK": 16, "AL": 2, "AN": 1},
"89+10": {"E": 10, "H": 10, "AK": 18, "AL": 2, "AN": 1},
"100+0": {"AF": 1},
"102+0": {"F": 10, "H": 10, "AK": 16, "AL": 1, "AM": 1},
"105+12": {"D": 10, "F": 10, "G": 10, "H": 10, "AO": 16, "AP": 2},
"112+0": {"E": 10, "H": 10, "AK": 16, "AL": 2, "AN": 1},
}
PRACTICE_SUM = {"C": "0", "D": "20", "E": "20", "F": "60", "G": "20", "H": "90", "AC": "10"}
PRACTICE_SUM |= {"AF": "2", "AK": "84", "AL": "9", "AM": "2", "AN": "3", "AO": "16"}
PRACTICE_SUM |= {"AP": "2", "AQ": "22", "AR": "3", "S": "0", "U": "0"}
def test_practice_totals_match_excel():
names = sorted(PRACTICE_SUM)
cols = [{"id": "B", "꼴": "글"}] + [{"id": n} for n in names if n not in ("S", "U")]
cols += [{"id": "Q"}, {"id": "R"}]
cols += [{"id": "S", "식": "[Q]*[R]"}, {"id": "U", "식": "INT([R]/6.00000001)*[Q]"}]
rows = [
{"id": f"r{i}", "값": {"B": sta, **vals}} for i, (sta, vals) in enumerate(PRACTICE.items())
]
doc = _doc(cols, rows, 합계줄=[{"id": "sum", "이름": "계", "식": "SUM"}])
out = _run(doc)
assert out["오류"] == []
assert {k: out["합계"]["sum"][k] for k in PRACTICE_SUM} == PRACTICE_SUM # 엑셀에 있는 합 칸만
assert out["계산"]["r0"] == {"S": "0", "U": "0"} # 엑셀 S5 · U5 = 0
def test_pavement_joint_coupling_and_rounding():
cols = [
{"id": "b"},
{"id": "l"},
{"id": "a", "식": "[b]*[l]"},
{"id": "jt", "식": "INT([l]/6.00000001)*[b]"},
{"id": "cp", "식": "ROUNDUP([l]/[$본],0)-1"},
{"id": "r", "식": "[l]/3", "끝수": {"자리": 2, "방법": "반올림"}},
{"id": "up", "식": "[l]/3", "끝수": {"자리": 1, "방법": "올림"}},
{"id": "dn", "식": "[b]*1.15*100", "끝수": {"자리": 0, "방법": "버림"}},
]
rows = [{"id": "r1", "값": {"b": 3, "l": 120}}, {"id": "r2", "값": {"b": "0.29", "l": 16}}]
out = _run(_doc(cols, rows, 변수={"본": 8}))
assert out["오류"] == []
assert out["계산"]["r1"] == {
"a": "360",
"jt": "57", # 120/6.00000001 = 19.99… → 19 × 3
"cp": "14", # 올림(120/8)−1
"r": "40",
"up": "40",
"dn": "345",
}
# 부동소수면 틀어지는 자리 — 0.29×1.15×100 = 33.35 → 버림 33 · 16/3 = 5.333… → 올림 5.4
assert out["계산"]["r2"] == {
"a": "4.64",
"jt": "0.58",
"cp": "1",
"r": "5.33",
"up": "5.4",
"dn": "33",
}
def test_row_override_total_map_and_cell_ref():
cols = [{"id": "sta", "꼴": "글"}, {"id": "x"}, {"id": "y", "식": "[x]*2"}]
rows = [
{"id": "r1", "값": {"sta": "전구간", "x": 5}, "고정": "전구간"},
{"id": "r2", "값": {"x": 7}, "식": {"y": "[x]+[x@r1]"}},
]
totals = [
{"id": "sum", "이름": "계", "식": {"x": "SUM", "*": "MAX([y@r1],[y@r2])"}},
{"id": "avg", "이름": "평균", "식": {"x": "ROUND([x@sum]/2,1)"}},
]
out = _run(_doc(cols, rows, 합계줄=totals))
assert out["오류"] == []
assert out["계산"] == {"r1": {"y": "10"}, "r2": {"y": "12"}}
assert out["합계"] == {"sum": {"x": "12", "y": "12"}, "avg": {"x": "6"}}
def test_errors_stay_in_their_cells():
cols = [
{"id": "x"},
{"id": "p", "식": "[q]+1"},
{"id": "q", "식": "[p]+1"},
{"id": "z", "식": "[x]/0"},
{"id": "n", "식": "[없음]+1"},
{"id": "t", "식": "[x]*2"},
{"id": "bad", "식": "[x]*("},
]
rows = [{"id": "r1", "값": {"x": "글자"}}, {"id": "r2", "값": {"x": 4}}]
out = _run(_doc(cols, rows))
why = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]}
assert "돌고 도는 참조" in why.values()
assert {("r2", "p"), ("r2", "q")} <= set(why)
assert "0 으로 나눔" in why[("r2", "z")]
assert "없는 열" in why[("r2", "n")]
assert "글" in why[("r1", "t")]
assert ("r2", "bad") in why
assert out["계산"]["r2"] == {"t": "8"} # 나머지 칸은 계속 풂
# ── 거울 — 파이썬 Decimal 로 따로 푼 답 ─────────────────────────────────
getcontext().prec = 60
TEMPLATES = {
"mul": ("[a]*[b]", lambda a, b, k: a * b),
"joint": (
"INT([b]/6.00000001)*[a]",
lambda a, b, k: (b / Decimal("6.00000001")).to_integral_value(ROUND_FLOOR) * a,
),
"ratio": (
"ROUND([a]/[b]*100,2)",
lambda a, b, k: (a / b * 100).quantize(Decimal("0.01"), ROUND_HALF_UP),
),
"band": (
"ROUNDUP([a]/8,0)-1",
lambda a, b, k: (a / 8).to_integral_value(ROUND_UP) - 1,
),
"down": (
"ROUNDDOWN([a]*1.15,1)",
lambda a, b, k: (a * Decimal("1.15")).quantize(Decimal("0.1"), ROUND_DOWN),
),
"mix": ("[a]-[b]*2+[$k]", lambda a, b, k: a - b * 2 + k),
}
def _plain(x: Decimal) -> str:
text = format(x.normalize(), "f")
return "0" if text in ("-0", "") else text
def test_mirror_random_docs_match_decimal():
rnd = random.Random(20260925)
docs, expected = [], []
for _ in range(40):
k = Decimal(rnd.randint(-500, 500)) / 10
rows, want = [], {}
for i in range(rnd.randint(1, 12)):
a = Decimal(rnd.randint(0, 99999)) / 100
b = Decimal(rnd.randint(1, 99999)) / 100
rows.append({"id": f"r{i}", "값": {"a": str(a), "b": str(b)}})
want[f"r{i}"] = {name: fn(a, b, k) for name, (_, fn) in TEMPLATES.items()}
cols = [{"id": "a"}, {"id": "b"}] + [{"id": n, "식": f} for n, (f, _) in TEMPLATES.items()]
docs.append(
_doc(cols, rows, 변수={"k": str(k)}, 합계줄=[{"id": "s", "이름": "계", "식": "SUM"}])
)
sums = {n: sum((w[n] for w in want.values()), Decimal(0)) for n in TEMPLATES}
expected.append((want, sums))
results = recalc_sheets(docs)
assert results is not None
for out, (want, sums) in zip(results, expected, strict=True):
assert out["오류"] == []
assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()}
for name, total in sums.items():
assert out["합계"]["s"][name] == _plain(total)
@@ -0,0 +1,294 @@
/* =============================================================================
* ui_template_sheet_formula.ts
* 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함.
*
* 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN ·
* MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수).
* `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음).
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음.
* 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터.
* ========================================================================== */
import {
add,
cmp,
div,
type Frac,
frac,
FormulaError,
type IntMode,
mul,
parseDecimal,
roundAt,
sub,
toInteger,
ZERO,
} from "./ui_template_sheet_frac";
type Token =
| { kind: "num"; text: string }
| { kind: "str"; text: string }
| { kind: "id"; text: string }
| { kind: "ref"; text: string }
| { kind: "op"; text: string };
export type Node =
| { type: "num"; value: Frac }
| { type: "str"; value: string }
| { type: "ref"; col: string; row: string | null }
| { type: "var"; name: string }
| { type: "unary"; op: string; arg: Node }
| { type: "binary"; op: string; left: Node; right: Node }
| { type: "call"; name: string; args: Node[] };
export type Value = Frac | string | boolean;
/** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */
export interface Scope {
cell(col: string, row: string | null): Value;
column(col: string): Frac[];
variable(name: string): Value;
}
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"];
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
const ROUNDERS: Record<string, IntMode> = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" };
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 === "[") {
const end = source.indexOf("]", i + 1);
if (end < 0) throw new FormulaError("「[」가 닫히지 않음");
tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() });
i = end + 1;
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].toUpperCase() });
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;
}
function refNode(text: string): Node {
if (!text) throw new FormulaError("빈 참조 []");
if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() };
const at = text.indexOf("@");
if (at < 0) return { type: "ref", col: text, row: null };
return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() };
}
/** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */
export function parseFormula(source: string): Node {
const tokens = tokenize(source.trim().replace(/^=/, ""));
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 = unary();
while (isOp("*") || isOp("/")) {
const op = tokens[at++].text;
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 === "ref") return refNode(token.text);
if (token.kind === "id") {
if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`);
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}`);
};
if (!tokens.length) throw new FormulaError("빈 식");
const tree = comparison();
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
return tree;
}
/** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */
export function* refsOf(node: Node): Generator<Node & { type: "ref" | "var" }> {
if (node.type === "ref" || node.type === "var") yield node;
else if (node.type === "unary") yield* refsOf(node.arg);
else if (node.type === "binary") {
yield* refsOf(node.left);
yield* refsOf(node.right);
} else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg);
}
const isFrac = (v: Value): v is Frac => typeof v === "object";
export 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 digitsOf(node: Node | undefined, scope: Scope): number {
if (!node) return 0;
const value = asFrac(evaluate(node, scope));
if (value.d !== 1n) throw new FormulaError("자리 수는 정수");
return Number(value.n);
}
export function evaluate(node: Node, scope: Scope): Value {
switch (node.type) {
case "num":
case "str":
return node.value;
case "ref":
return scope.cell(node.col, node.row);
case "var":
return scope.variable(node.name);
case "unary": {
const value = asFrac(evaluate(node.arg, scope));
return node.op === "-" ? sub(ZERO, value) : 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]!;
}
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);
return div(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 === "SUM") {
let total = ZERO;
for (const arg of args) {
const values =
arg.type === "ref" && arg.row === null
? scope.column(arg.col)
: [asFrac(evaluate(arg, scope))];
for (const value of values) total = add(total, value);
}
return total;
}
if (name === "INT") {
if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나");
return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor"));
}
if (name in ROUNDERS) {
if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`);
return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]);
}
if (name === "MIN" || name === "MAX") {
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
}
throw new FormulaError(`모르는 함수: ${name}`);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
/* =============================================================================
* ui_template_sheet_frac.ts
* 표 식의 수 — BigInt 분수. 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 의 분수 몫을 되살림.
*
* ⚠ 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐 — 실무 엑셀 `INT(x*100)/100` 이
* 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음. 서버(파이썬 Decimal)와 1원도 안 갈림.
* ========================================================================== */
export class FormulaError extends Error {}
export interface Frac {
n: bigint;
d: bigint;
}
/** 정수로 떨구는 법 — floor = 엑셀 INT · trunc = ROUNDDOWN · away = ROUNDUP · round = ROUND. */
export type IntMode = "floor" | "trunc" | "away" | "round";
const DIGITS = 30;
const TEN = 10n;
const abs = (x: bigint): bigint => (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;
}
export 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 };
}
export const ZERO = frac(0n);
export const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
export const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
export const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
export function div(a: Frac, b: Frac): Frac {
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
return frac(a.n * b.d, a.d * b.n);
}
export function 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` · `1,234`)을 분수로. */
export function parseDecimal(text: string): Frac {
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.replace(/,/g, "").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);
}
/** 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음. */
export function toFrac(value: number | string): Frac {
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
return parseDecimal(String(value));
}
return parseDecimal(value);
}
export function toInteger(x: Frac, mode: IntMode): 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 === "trunc") return q;
if (mode === "away") return negative ? q - 1n : q + 1n;
// 사사오입 — 엑셀 ROUND 와 같이 0 에서 먼 쪽
return abs(r) * 2n >= x.d ? (negative ? q - 1n : q + 1n) : q;
}
/** 소수 `digits` 자리에서 떨굼(음수 자리 = 십 · 백 자리). */
export function roundAt(x: Frac, digits: number, mode: IntMode): Frac {
const places = Math.trunc(digits);
const scale = places >= 0 ? frac(TEN ** BigInt(places)) : frac(1n, TEN ** BigInt(-places));
return div(frac(toInteger(mul(x, scale), 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;
}
@@ -0,0 +1,26 @@
/* =============================================================================
* ui_template_sheet_node.ts
* 표 문서 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본으로 다시 풂.
*
* 풀이는 화면이 쓰는 `ui_template_sheet_recalc.ts` 그대로 — 여기에는 계산이 없음.
* 파이썬 껍데기 = `common_util/common_util_sheet_recalc.py` · 번들 = `npm run build:formula`.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { docs: SheetDoc[] }
* 출력 { results: SheetResult[] } — 입력 차례 그대로
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { recalcSheet } from "./ui_template_sheet_recalc";
import type { SheetDoc } from "./ui_template_sheet_types";
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 { docs?: SheetDoc[] };
const results = (input.docs ?? []).map((doc) => recalcSheet(doc));
writeFileSync(outputPath, JSON.stringify({ results }));
@@ -0,0 +1,178 @@
/* =============================================================================
* ui_template_sheet_recalc.ts
* 표 문서 한 벌 풀이 — 계산 열 · 한 칸만 다른 식(`줄.식`) · 합계 줄(열마다 다른 식) · 변수.
*
* 화면(조작 중 즉시)과 서버(`ui_template_sheet_node.ts` — [저장] 때 정본 재계산)가
* **이 파일 하나**를 같이 씀(CLAUDE.md 5장 ②). 두 벌로 짜면 끝수가 조용히 갈림.
* 순환은 풀면서 잡음 — 풀고 있는 칸을 다시 부르면 그 고리의 칸마다 오류.
* 오류 칸은 값 없이 `오류` 에 까닭과 함께 — 나머지 칸은 계속 풂.
* ========================================================================== */
import {
type Frac,
FormulaError,
fracToString,
roundAt,
toFrac,
ZERO,
} from "./ui_template_sheet_frac";
import {
evaluate,
parseFormula,
type Node,
type Scope,
type Value,
} from "./ui_template_sheet_formula";
import type {
SheetColumn,
SheetDoc,
SheetError,
SheetResult,
SheetRounding,
SheetTotal,
} from "./ui_template_sheet_types";
const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const;
export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글";
/** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */
export function totalFormula(total: SheetTotal, col: SheetColumn): string | null {
if (!isNumberColumn(col)) return null;
const text = typeof total.식 === "string" ? total.식 : (total.식[col.id] ?? total.식["*"]);
if (!text || !text.trim()) return null;
return text.trim().toUpperCase() === "SUM" ? `SUM([${col.id}])` : text;
}
/** 칸 값 → 수 · 글. 빈 칸은 null. */
function inputValue(raw: unknown): Value | null {
if (raw === null || raw === undefined || raw === "") return null;
if (typeof raw === "number") return toFrac(raw);
const text = String(raw).trim();
if (text === "") return null;
try {
return toFrac(text);
} catch {
return text;
}
}
function applyRound(value: Value, rounding: SheetRounding | null | undefined): Value {
if (!rounding || typeof value !== "object") return value;
return roundAt(value, rounding.자리 ?? 0, ROUND_MODE[rounding.방법] ?? "round");
}
const show = (value: Value): string =>
typeof value === "object"
? fracToString(value)
: typeof value === "boolean"
? value
? "1"
: "0"
: value;
class Failed {
constructor(readonly message: string) {}
}
export function recalcSheet(doc: SheetDoc): SheetResult {
const cols = new Map(doc.열.map((c) => [c.id, c]));
const rows = new Map(doc.줄.map((r) => [r.id, r]));
const totals = new Map((doc.합계줄 ?? []).map((t) => [t.id, t]));
const parsed = new Map<string, Node | Failed>();
const memo = new Map<string, Value | null | Failed>();
const visiting = new Set<string>();
const result: SheetResult = { 계산: {}, 합계: {}, 오류: [] };
const parse = (text: string): Node => {
let node = parsed.get(text);
if (!node) {
try {
node = parseFormula(text);
} catch (error) {
node = new Failed(error instanceof Error ? error.message : String(error));
}
parsed.set(text, node);
}
if (node instanceof Failed) throw new FormulaError(node.message);
return node;
};
const formulaOf = (rowId: string, col: SheetColumn): string | null => {
const row = rows.get(rowId);
if (row) return row.식?.[col.id] ?? col.식 ?? null;
return totalFormula(totals.get(rowId)!, col);
};
/** 한 칸 — 식 칸이면 풀어 끝수까지 · 입력 칸이면 값. 빈 칸 null. */
const cell = (rowId: string, colId: string): Value | null => {
const col = cols.get(colId);
if (!col) throw new FormulaError(`없는 열: ${colId}`);
if (!rows.has(rowId) && !totals.has(rowId)) throw new FormulaError(`없는 줄: ${rowId}`);
const key = `${rowId}\u0000${colId}`;
if (memo.has(key)) {
const hit = memo.get(key)!;
if (hit instanceof Failed) throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
return hit;
}
const formula = formulaOf(rowId, col);
if (!formula) {
const value = rows.has(rowId) ? inputValue(rows.get(rowId)!.값[colId]) : null;
memo.set(key, value);
return value;
}
if (visiting.has(key)) throw new FormulaError("돌고 도는 참조");
visiting.add(key);
try {
const value = applyRound(evaluate(parse(formula), scopeFor(rowId)), col.끝수);
memo.set(key, value);
return value;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
memo.set(key, new Failed(message));
result.오류.push({ 줄: rowId, 열: colId, 까닭: message } satisfies SheetError);
throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
} finally {
visiting.delete(key);
}
};
const scopeFor = (rowId: string): Scope => ({
cell: (col, row) => cell(row ?? rowId, col) ?? ZERO,
column: (col) => {
const out: Frac[] = [];
for (const id of rows.keys()) {
const value = cell(id, col);
if (typeof value === "object" && value !== null) out.push(value);
}
return out;
},
variable: (name) => {
const raw = doc.변수?.[name];
if (raw === undefined) throw new FormulaError(`없는 변수: $${name}`);
return inputValue(raw) ?? ZERO;
},
});
const solve = (rowId: string, into: Record<string, Record<string, string>>): void => {
for (const col of doc.열) {
if (!formulaOf(rowId, col)) continue;
try {
const value = cell(rowId, col.id);
(into[rowId] ??= {})[col.id] = value === null ? "" : show(value);
} catch {
// 까닭은 `오류` 에 이미 적힘
}
}
};
for (const id of rows.keys()) solve(id, result.계산);
for (const id of totals.keys()) solve(id, result.합계);
return result;
}
/** 화면 표시용 — 십진 글에 천 단위 쉼표. */
export function groupDigits(text: string): string {
const match = /^(-?)(\d+)(\.\d+)?$/.exec(text);
if (!match) return text;
return `${match[1]}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${match[3] ?? ""}`;
}
@@ -0,0 +1,92 @@
/* =============================================================================
* ui_template_sheet_types.ts
* 표 문서(마스터 템플릿 표 양식) 모양 — `tmp/M02_분석/3_표양식.md` 4장 + 창끼리 계약 「표 문서」.
*
* 줄 `값` 에는 입력만 둠 — 계산 열 · 합계 줄 값은 저장하지 않고 열 때마다 다시 풂.
* 식은 열 id · 줄 id 로 참조 — 줄 · 열을 끼우거나 지워도 식이 안 깨짐.
* ========================================================================== */
export type SheetCell = string | number | null;
/** 끝수 — 방법 이름은 M01 `master_formula` 와 같음(반올림 · 올림 · 버림). */
export interface SheetRounding {
자리: number;
방법: "반올림" | "올림" | "버림";
}
/** 설계 출처 — sub4 가 채움. 표 부품은 있으면 project 에서 그 열을 잠금. */
export interface SheetBinding {
종류: string;
펼침?: string[];
값?: string;
}
export interface SheetColumn {
id: string;
/** 층 수만큼 · `null` = 위 칸과 합침(세로) · 옆 열과 앞머리가 같으면 가로로 합침. */
머리: (string | null)[];
단위?: string | null;
꼴?: "수" | "글";
/** 줄마다 같은 식 — 있으면 계산 칸(잠김). */
식?: string;
끝수?: SheetRounding | null;
/** 마스터에서 보일 「들어갈 것」 글. */
설명?: string;
바인딩?: SheetBinding | null;
/** 일위대가 조합 키. */
일위대가?: string | null;
/** 마스터 한 칸 → 프로젝트에서 설계 값마다 열. */
펼침?: boolean;
/** 사용자 입력 열. */
손?: boolean;
}
export interface SheetRow {
id: string;
값: Record<string, SheetCell>;
/** 이 줄에서만 다른 식 — 열 식을 이김. */
식?: Record<string, string>;
/** `전구간` = 맨 위 손 입력 줄. */
고정?: string;
/** 사용자가 더한 줄 — project 에서 측점을 고치고 지울 수 있음. */
손?: boolean;
}
export interface SheetTotal {
id: string;
이름: string;
/** `"SUM"` = 수 열마다 열 합 · 묶음 = 열마다 다른 식(`"*"` = 나머지 열). */
식: string | Record<string, string>;
}
export interface SheetView {
열너비?: Record<string, number>;
틀고정?: { 열?: number };
}
export interface SheetDoc {
양식: string;
종류: "표";
판: number;
층?: string[];
변수?: Record<string, string | number>;
/** 한 쪽 줄 수 — 쪽마다 머리 반복 · 합계는 마지막 쪽. */
쪽줄?: number;
열: SheetColumn[];
줄: SheetRow[];
합계줄?: SheetTotal[];
보기?: SheetView;
}
export interface SheetError {
줄: string;
열: string;
까닭: string;
}
/** 재계산 결과 — 값은 십진 문자열(끝수 뒤). 화면과 서버 Node 가 같은 모양을 냄. */
export interface SheetResult {
계산: Record<string, Record<string, string>>;
합계: Record<string, Record<string, string>>;
오류: SheetError[];
}