시험 고침: 534-3 양식 시험의 시트 목록 · 쪽 수 기대(목차 · 부록 더함) · E05 시험 시트 수 7 → 9 · PDF = report_pdf(목차 쪽 번호 = 실제 · 꼬리말 · 머리말) · 부록 절편 · 측점표 쪽 나눔 · 옛 작업본 갱신 시험 덧. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
7.8 KiB
Python
198 lines
7.8 KiB
Python
"""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
|