"""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_print_pdf import PrintError from common_util.common_util_spreadsheet import with_server_values from common_util.common_util_svg_png import svgs_to_pngs from E05_StabReport import E05_StabReport_Print as printer 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 _numbered(book: dict[str, Any]) -> dict[str, Any]: """목차 쪽 번호를 실제 쪽 수로(브라우저 없으면 「-」 그대로 · PDF 내보내기 때 다시 셈).""" try: printer.number_toc(book) except PrintError: pass return book 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 = _numbered(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 _merge_styles(have: list[dict], form_styles: list[dict], sheets: Any) -> list[dict]: """새 판 시트의 서식 번호(양식 기준)를 작업본 서식 표 번호로 — 없는 서식은 끝에 붙임 (옛 작업본 · 사용자가 더한 서식과 번호가 엇갈리지 않게).""" styles = list(have) remap: dict[int, int] = {} for i, style in enumerate(form_styles): if style not in styles: styles.append(style) remap[i] = styles.index(style) for sheet in sheets: for cell in sheet["칸"].values(): if "서식" in cell: cell["서식"] = remap.get(cell["서식"], cell["서식"]) return styles 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} book = {**book, "서식": _merge_styles(book.get("서식") or [{}], fresh["서식"], built.values())} 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["시트"] if s["id"] in built or s["id"] not in PROGRAM_SHEETS ] order = [s["id"] for s in fresh["시트"]] for sid in order: # 없는 프로그램 몫(지움 · 새로 생긴 목차 · 부록) = 양식 차례 자리에 if sid in built and sid not in have: before = {x for x in order[: order.index(sid)]} at = max((i + 1 for i, x in enumerate(sheets) if x["id"] in before), default=0) sheets.insert(at, built[sid]) book = _numbered(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