From 02ba8cbf02d66bc83d332976b91813dd10af70a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 09:36:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(sheet):=20=ED=91=9C=20=EC=8B=9D=20?= =?UTF-8?q?=ED=92=80=EC=9D=B4=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EC=84=9C=EB=B2=84=20Node=20?= =?UTF-8?q?=EA=B0=80=20=EA=B0=99=EC=9D=B4=20=EC=94=80=20(PLAN=2010-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 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 Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1 --- common_util/common_util_sheet_recalc.py | 39 +++ package.json | 2 +- resources/tester/test_sheet_recalc.py | 195 ++++++++++++ .../sheet/ui_template_sheet_formula.ts | 294 ++++++++++++++++++ ui_template/sheet/ui_template_sheet_frac.ts | 106 +++++++ ui_template/sheet/ui_template_sheet_node.ts | 26 ++ ui_template/sheet/ui_template_sheet_recalc.ts | 178 +++++++++++ ui_template/sheet/ui_template_sheet_types.ts | 92 ++++++ 8 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 common_util/common_util_sheet_recalc.py create mode 100644 resources/tester/test_sheet_recalc.py create mode 100644 ui_template/sheet/ui_template_sheet_formula.ts create mode 100644 ui_template/sheet/ui_template_sheet_frac.ts create mode 100644 ui_template/sheet/ui_template_sheet_node.ts create mode 100644 ui_template/sheet/ui_template_sheet_recalc.ts create mode 100644 ui_template/sheet/ui_template_sheet_types.ts diff --git a/common_util/common_util_sheet_recalc.py b/common_util/common_util_sheet_recalc.py new file mode 100644 index 00000000..e65db0c5 --- /dev/null +++ b/common_util/common_util_sheet_recalc.py @@ -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] diff --git a/package.json b/package.json index 2304830f..4fff73b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/tester/test_sheet_recalc.py b/resources/tester/test_sheet_recalc.py new file mode 100644 index 00000000..07b21e6f --- /dev/null +++ b/resources/tester/test_sheet_recalc.py @@ -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) diff --git a/ui_template/sheet/ui_template_sheet_formula.ts b/ui_template/sheet/ui_template_sheet_formula.ts new file mode 100644 index 00000000..0ee72964 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_formula.ts @@ -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 = { 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 { + 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}`); + } + } +} diff --git a/ui_template/sheet/ui_template_sheet_frac.ts b/ui_template/sheet/ui_template_sheet_frac.ts new file mode 100644 index 00000000..ada202ac --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_frac.ts @@ -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; +} diff --git a/ui_template/sheet/ui_template_sheet_node.ts b/ui_template/sheet/ui_template_sheet_node.ts new file mode 100644 index 00000000..368d5033 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_node.ts @@ -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 })); diff --git a/ui_template/sheet/ui_template_sheet_recalc.ts b/ui_template/sheet/ui_template_sheet_recalc.ts new file mode 100644 index 00000000..b4e8cd2a --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_recalc.ts @@ -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(); + const memo = new Map(); + const visiting = new Set(); + 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>): 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] ?? ""}`; +} diff --git a/ui_template/sheet/ui_template_sheet_types.ts b/ui_template/sheet/ui_template_sheet_types.ts new file mode 100644 index 00000000..29cd5a36 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_types.ts @@ -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; + /** 이 줄에서만 다른 식 — 열 식을 이김. */ + 식?: Record; + /** `전구간` = 맨 위 손 입력 줄. */ + 고정?: string; + /** 사용자가 더한 줄 — project 에서 측점을 고치고 지울 수 있음. */ + 손?: boolean; +} + +export interface SheetTotal { + id: string; + 이름: string; + /** `"SUM"` = 수 열마다 열 합 · 묶음 = 열마다 다른 식(`"*"` = 나머지 열). */ + 식: string | Record; +} + +export interface SheetView { + 열너비?: Record; + 틀고정?: { 열?: number }; +} + +export interface SheetDoc { + 양식: string; + 종류: "표"; + 판: number; + 층?: string[]; + 변수?: Record; + /** 한 쪽 줄 수 — 쪽마다 머리 반복 · 합계는 마지막 쪽. */ + 쪽줄?: number; + 열: SheetColumn[]; + 줄: SheetRow[]; + 합계줄?: SheetTotal[]; + 보기?: SheetView; +} + +export interface SheetError { + 줄: string; + 열: string; + 까닭: string; +} + +/** 재계산 결과 — 값은 십진 문자열(끝수 뒤). 화면과 서버 Node 가 같은 모양을 냄. */ +export interface SheetResult { + 계산: Record>; + 합계: Record>; + 오류: SheetError[]; +}