"""산출근거 스프레드시트(통합문서) 서버 재계산 — 화면과 같은 TS 엔진을 Node 로 돌리는 껍데기. CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는 번들을 부르기만 함 (`npm run build:spreadsheet` → `config/spreadsheet_node/`). [저장] 때 브라우저가 보낸 `계산값` 은 버리고 이 결과를 정본으로 적음. 결과 한 권 = `{시트 id: {A1: {수|글|참|오류}}}`(식 칸만) · 수는 십진 글. """ from __future__ import annotations import copy from typing import Any from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json BUNDLE = ROOT / "config" / "spreadsheet_node" / "common_util_spreadsheet_node.js" NPM_SCRIPT = "build:spreadsheet" ENTRY = ROOT / "common_util" / "common_util_spreadsheet_node.ts" SOURCES = (ROOT / "A00_Common" / "spreadsheet", ROOT / "ui_template" / "sheet") class RecalcError(Exception): """번들 빌드 · 실행 실패 또는 엔진이 그 통합문서를 못 풂 — 저장을 막음(검산 없이 적지 않음).""" def _stale() -> bool: """번들이 없거나 엔진 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄.""" if not BUNDLE.is_file(): return True built_at = BUNDLE.stat().st_mtime paths = [ENTRY, *(p for folder in SOURCES for p in folder.glob("*.ts"))] return any(p.stat().st_mtime > built_at for p in paths) def recalc_workbooks(books: list[dict[str, Any]]) -> list[dict[str, Any]]: """통합문서 여럿 → 입력 차례 그대로 `계산값`. 하나라도 못 풀면 `RecalcError`.""" if _stale() and not build_bundle(NPM_SCRIPT): raise RecalcError("스프레드시트 계산 번들을 못 만듦") out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"books": books}) if out is None: raise RecalcError("스프레드시트 계산 번들 실행 실패") values: list[dict[str, Any]] = [] for result in out["results"]: if not result.get("ok"): raise RecalcError(f"스프레드시트 계산 실패 — {result.get('까닭')}") values.append(result["계산값"]) return values def with_server_values(book: dict[str, Any]) -> dict[str, Any]: """[저장] 직전 — 브라우저 `계산값` 은 버리고 서버 풀이로 새로 적은 사본.""" clean = copy.deepcopy(book) clean.pop("계산값", None) clean["계산값"] = recalc_workbooks([clean])[0] return clean