"""E05 보고서 storage — 작업본 · 초기값 · 그림 · 옛 판(요구 347-5 · PLAN 534-3). storage(E 프로젝트 루트): E05_StabReport/report.json 작업본(Workbook · 서버 재계산 `계산값` 포함) E05_StabReport/report_meta.json 해석 지문 · 만든 때 · 프로그램 몫 시트 서명(덮임 알림용) E05_StabReport/fig/<열쇠>.png 그림(열쇠 = `fig/<측점 키>.png` · `fig/3d_band.png`) E05_StabReport/_지난/<때>.json 갱신이 덮은 옛 판(덮인 시트에 사용자 고침이 있었을 때) initial_snapshot/E05_StabReport/ 초기값 = 처음 만든 직후 한 벌(report · meta · fig) 시트 채우기 · 그림 SVG = `E05_StabReport_Build.py` · 데이터 3층 = CLAUDE.md 5장. """ from __future__ import annotations import hashlib import shutil from datetime import datetime from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json from common_util.common_util_spreadsheet import with_server_values from common_util.common_util_svg_png import svgs_to_pngs from E05_StabReport.E05_StabReport_Build import ( META, PROGRAM_SHEETS, REPORT, _read, build_program, fig_path, fill_cover, form, gather, initial_dir, sheet_sign, stage_dir, ) def revision(project_root: Path) -> str | None: path = stage_dir(project_root) / REPORT return hashlib.sha1(path.read_bytes()).hexdigest()[:16] if path.is_file() else None def load(project_root: Path) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: folder = stage_dir(project_root) return _read(folder / REPORT), _read(folder / META) def _write_figures(folder: Path, jobs: list[tuple[str, str]]) -> list[str]: pngs = svgs_to_pngs([svg for _, svg in jobs]) target = Path(folder) / "fig" if target.is_dir(): shutil.rmtree(target) target.mkdir(parents=True) for (key, _), png in zip(jobs, pngs): fig_path(folder, key).write_bytes(png) return [key for key, _ in jobs] def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> None: folder = stage_dir(project_root) atomic_write_json(folder / REPORT, book) atomic_write_json(folder / META, meta) def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]: return { "fingerprint": inputs["fingerprint"], "built_at": datetime.now().isoformat(timespec="seconds"), "signs": {s["id"]: sheet_sign(s) for s in book["시트"] if s["id"] in PROGRAM_SHEETS}, "figures": figures, } def create(project_root: Path) -> dict[str, Any]: """처음 만듦 — 양식 전부 · 사용자 몫 처음 채움 · 초기값 한 벌. 돌려줌 = meta.""" inputs = gather(project_root) book = form() fill_cover(next(s for s in book["시트"] if s["id"] == "s1"), inputs) jobs = build_program(book, inputs) book = with_server_values(book) folder = stage_dir(project_root) figures = _write_figures(folder, jobs) meta = _meta(book, inputs, figures) _write(project_root, book, meta) init = initial_dir(project_root) if init.is_dir(): shutil.rmtree(init) init.mkdir(parents=True) for name in (REPORT, META): shutil.copy2(folder / name, init / name) shutil.copytree(folder / "fig", init / "fig") return meta def refresh(project_root: Path) -> dict[str, Any]: """[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로. 덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`.""" book, meta = load(project_root) if book is None: return {"created": True, "overwritten": [], **create(project_root)} inputs = gather(project_root) fresh = form() jobs = build_program(fresh, inputs) built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS} signs = (meta or {}).get("signs") or {} overwritten = [ s["이름"] for s in book["시트"] if s["id"] in built and signs.get(s["id"]) and sheet_sign(s) != signs[s["id"]] ] folder = stage_dir(project_root) if overwritten: old = folder / "_지난" old.mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") shutil.copy2(folder / REPORT, old / f"{stamp}.json") have = {s["id"] for s in book["시트"]} sheets = [built.get(s["id"], s) for s in book["시트"]] sheets += [built[sid] for sid in PROGRAM_SHEETS if sid not in have] # 지운 시트는 끝에 다시 book = with_server_values({**book, "시트": sheets}) figures = _write_figures(folder, jobs) new_meta = _meta(book, inputs, figures) _write(project_root, book, new_meta) return {"created": False, "overwritten": overwritten, **new_meta} def reset(project_root: Path) -> bool: """[초기화] — 초기값을 작업본에 덮어씀(재계산 아님) · 초기값 없으면 False.""" init = initial_dir(project_root) if not (init / REPORT).is_file(): return False folder = stage_dir(project_root) folder.mkdir(parents=True, exist_ok=True) for name in (REPORT, META): shutil.copy2(init / name, folder / name) if (folder / "fig").is_dir(): shutil.rmtree(folder / "fig") shutil.copytree(init / "fig", folder / "fig") return True def save(project_root: Path, book: dict[str, Any]) -> None: """[저장] — 브라우저 `계산값` 은 버리고 서버 풀이로 · meta(지문 · 서명)는 그대로.""" clean = with_server_values(book) atomic_write_json(stage_dir(project_root) / REPORT, clean) def figures_of(project_root: Path, book: dict[str, Any]) -> dict[str, bytes]: """책이 가리키는 그림 열쇠 → PNG(있는 것만) — xlsx · PDF 내보내기용.""" folder = stage_dir(project_root) out: dict[str, bytes] = {} for sheet in book.get("시트") or []: for fig in sheet.get("그림") or []: path = fig_path(folder, fig["열쇠"]) if path.is_file(): out[fig["열쇠"]] = path.read_bytes() return out