diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py
new file mode 100644
index 000000000..6606c2a6c
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Build.py
@@ -0,0 +1,593 @@
+"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
+
+입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 ·
+대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선).
+양식 = `resources/master_template/report/stab_report.json`
+칸 자리 = 같은 폴더 `stab_report_slots.md`.
+
+몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
+프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6)
+= [보고서 갱신] 때 양식에서 통째로 다시 씀.
+판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
+(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
+
+storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`.
+
+계산 자리 = ③ 서버 단독(값 셈 없음 · 결과 옮겨 적기 · 수식은 공용 Node 엔진).
+"""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+from datetime import date
+from pathlib import Path
+from typing import Any
+
+from E01_StabRegister import E01_StabRegister_Source as source
+from E02_StabGround import E02_StabGround_Model as ground_model
+from E03_StabSection.E03_StabSection_Engine_Model import final_surface
+from E04_Stab3D import E04_Stab3D_Run as run3d
+from E05_StabReport import E05_StabReport_Figure as figure
+
+ROOT = Path(__file__).resolve().parents[1]
+FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json"
+STAGE = "E05_StabReport"
+REPORT, META = "report.json", "report_meta.json"
+USER_SHEETS = ("s1", "s2")
+PROGRAM_SHEETS = ("s3", "s4", "s5", "s6")
+BLOCK = 28 # 대표단면 한 쪽 = 28 행
+E03 = Path("E03_StabSection")
+RESULT2D = E03 / "_derived" / "result2d.json"
+STATION_RESULTS = E03 / "_derived" / "results"
+REPRESENTATIVE = E03 / "representative.json"
+BAND_KEY = "fig/3d_band.png"
+LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N
+CASES = ("dry", "wet", "seismic")
+SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
+METHOD_NAMES = {
+ "bishop": "Bishop 간편법",
+ "janbu": "Janbu 보정",
+ "janbu_corrected": "Janbu 보정",
+ "ordinary": "Fellenius",
+ "spencer": "Spencer",
+}
+REASONS = {
+ "fill_10m": "쌓기 높이 10 m 이상",
+ "cut_15m": "깎기 높이 15 m 이상",
+ "lowest_fs": "최소 안전율 하위",
+ "user": "사용자 지정",
+}
+GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
+ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17
+A1 = re.compile(r"^([A-Z]+)(\d+)$")
+
+
+class MissingInput(Exception):
+ """보고서를 만들 해석 결과가 없음(2D 해석 전)."""
+
+
+# ── 읽기 ─────────────────────────────────────────────────────────────────────
+def _read(path: Path) -> Any:
+ return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
+
+
+def stage_dir(project_root: Path) -> Path:
+ return Path(project_root) / STAGE
+
+
+def initial_dir(project_root: Path) -> Path:
+ return Path(project_root) / "initial_snapshot" / STAGE
+
+
+def fig_key(station_key: str) -> str:
+ return f"fig/{station_key}.png"
+
+
+def fig_path(folder: Path, key: str) -> Path:
+ """그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`)."""
+ name = key.removeprefix("fig/")
+ return Path(folder) / "fig" / re.sub(r"[^0-9A-Za-z가-힣._+-]", "_", name)
+
+
+def form() -> dict[str, Any]:
+ return json.loads(FORM_PATH.read_text(encoding="utf-8"))
+
+
+def _ground(project_root: Path) -> dict[str, Any]:
+ return (
+ _read(ground_model.ground_path(project_root))
+ or _read(ground_model.initial_path(project_root))
+ or ground_model.pick(ground_model.load_defaults())
+ )
+
+
+def _station_result(project_root: Path, key: str) -> dict[str, Any] | None:
+ folder = Path(project_root) / STATION_RESULTS
+ for name in (key, re.sub(r"[^0-9A-Za-z._+-]", "_", key)):
+ found = _read(folder / f"{name}.json")
+ if found:
+ return found
+ return None
+
+
+def analysis_fingerprint(project_root: Path) -> str:
+ """해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음
+ (가벼운 읽기만)."""
+ project_root = Path(project_root)
+ r2 = _read(project_root / RESULT2D)
+ folder = run3d.results_dir(project_root)
+ body = {
+ "2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None,
+ "rep": _read(project_root / REPRESENTATIVE),
+ "ground": _ground(project_root),
+ "3d": [
+ [p.name, p.stat().st_size, p.stat().st_mtime_ns] for p in sorted(folder.glob("*.json"))
+ ]
+ if folder.is_dir()
+ else [],
+ "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(),
+ }
+ text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()
+
+
+def _representatives(r2: dict[str, Any], rep: dict[str, Any] | None) -> list[dict[str, Any]]:
+ by_key = {s["station_key"]: s for s in r2.get("stations") or []}
+ if rep and isinstance(rep.get("stations"), list):
+ picked = [s for s in rep["stations"] if s.get("enabled", True)]
+ else:
+ ranked = sorted(
+ (s for s in by_key.values() if s.get("fs_min") is not None), key=lambda s: s["fs_min"]
+ )
+ picked = [{"station_key": s["station_key"], "reason": "lowest_fs"} for s in ranked][
+ :LOWEST_N
+ ]
+ return [
+ {**p, "summary": by_key[p["station_key"]]} for p in picked if p["station_key"] in by_key
+ ]
+
+
+def gather(project_root: Path) -> dict[str, Any]:
+ """빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`."""
+ project_root = Path(project_root)
+ r2 = _read(project_root / RESULT2D)
+ if not r2 or not r2.get("stations"):
+ raise MissingInput("2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.")
+ root = source.source_dir(project_root)
+ db = source.db_snapshot(root)
+ ground = _ground(project_root)
+ resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)}
+ reps = _representatives(r2, _read(project_root / REPRESENTATIVE))
+ for item in reps:
+ chainage = float(item["summary"]["chainage_m"])
+ item["result"] = _station_result(project_root, item["station_key"])
+ try:
+ item["cross"] = source.cross_section(root, chainage)
+ except KeyError:
+ item["cross"] = None
+ item["layers"] = resolved.get(round(chainage, 3))
+ try:
+ sections3d = run3d.saved_summaries(project_root)
+ except (OSError, ValueError, KeyError):
+ sections3d = []
+ return {
+ "project": db.get("project") or {},
+ "route_points": sorted(db.get("route_points") or [], key=lambda p: p["sequence_num"]),
+ "stations": source.stations(root),
+ "result2d": r2,
+ "representatives": reps,
+ "ground": ground,
+ "sections3d": sorted(sections3d, key=lambda s: s["start_m"]),
+ "fingerprint": analysis_fingerprint(project_root),
+ }
+
+
+# ── 칸 쓰기 ──────────────────────────────────────────────────────────────────
+def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
+ """값(None = 지움) · 서식(None = 양식 그대로)."""
+ cell = dict(sheet["칸"].get(a1) or {})
+ cell.pop("식", None)
+ if value is None or value == "":
+ cell.pop("값", None)
+ else:
+ cell["값"] = round(value, 6) if isinstance(value, float) else value
+ if style is not None:
+ cell["서식"] = style
+ if cell:
+ sheet["칸"][a1] = cell
+ else:
+ sheet["칸"].pop(a1, None)
+
+
+def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None:
+ for col in cols:
+ cell = sheet["칸"].get(f"{col}{src}")
+ if cell and dst != src:
+ sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {}
+
+
+def _shift(a1: str, dr: int) -> str:
+ m = A1.match(a1)
+ return f"{m.group(1)}{int(m.group(2)) + dr}"
+
+
+def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None:
+ if ok is None:
+ _set(sheet, a1, None)
+ else:
+ _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG)
+
+
+def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float:
+ sf = (ground.get("safety_factors") or {}).get(case) or {}
+ if side in ("cut", "fill"):
+ return float(sf[side])
+ return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽
+
+
+def _seismic(ground: dict[str, Any]) -> bool:
+ return bool((ground.get("seismic") or {}).get("enabled"))
+
+
+def _labels(stations: list[dict[str, Any]]) -> dict[float, str]:
+ return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations}
+
+
+def _label_at(labels: dict[float, str], chainage: float) -> str:
+ if not labels:
+ return f"{chainage:.2f}"
+ near = min(labels, key=lambda c: abs(c - chainage))
+ return labels[near] or f"{chainage:.2f}"
+
+
+# ── 사용자 몫(처음 한 번) ────────────────────────────────────────────────────
+def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
+ """표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀."""
+ project = inputs["project"]
+ _set(sheet, "C8", project.get("name"))
+ _set(
+ sheet,
+ "C9",
+ " · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v),
+ )
+ _set(sheet, "C11", date.today().isoformat())
+
+
+# ── 프로그램 몫 ──────────────────────────────────────────────────────────────
+WATER_TEXT = {
+ "none": "지하수 없음",
+ "surface": "지하수위 = 공사 뒤 지표(포화)",
+ "half_height": "비탈면 높이 ½ 심도까지 포화",
+}
+
+
+def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
+ """s3 입력조건."""
+ ground = inputs["ground"]
+ _set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교")
+ _set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로")
+ _set(
+ sheet,
+ "B7",
+ "Hungr 3D Bishop(+Janbu 보정) · 구 · 타원체 탐색 — 「3D요약」 시트"
+ if inputs["sections3d"]
+ else "3D 해석 전",
+ )
+ _set(sheet, "B8", "KDS 11 70 05 비탈면 기준 안전율")
+ for r, case in ((12, "dry"), (13, "wet"), (14, "seismic")):
+ if case == "seismic" and not _seismic(ground):
+ _set(sheet, f"B{r}", "-")
+ _set(sheet, f"C{r}", "지진 검토 안 함")
+ continue
+ sf = ground["safety_factors"][case]
+ _set(sheet, f"B{r}", _criterion(ground, case))
+ _set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽")
+ water = ground.get("groundwater") or {}
+ _set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-"))
+ _set(
+ sheet,
+ "B18",
+ f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
+ f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}",
+ )
+ seismic = ground.get("seismic") or {}
+ _set(
+ sheet,
+ "B19",
+ f"건기 수위 + 수평지진계수 kh = {float(seismic.get('kh') or 0):.2f}"
+ if _seismic(ground)
+ else "적용 안 함",
+ )
+ _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0))
+ defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]}
+ names = {m["id"]: m["name"] for m in ground.get("materials") or []}
+ rows: list[tuple[dict[str, Any], str]] = [(m, "전체") for m in ground.get("materials") or []]
+ for zone in ground.get("zones") or []:
+ span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m"
+ for layer, where in (("soil", "위"), ("rock", "아래")):
+ mid = zone.get(layer)
+ match = next((m for m in ground["materials"] if m["id"] == mid), None)
+ if match:
+ rows.append((match, f"{span} {where} 지층"))
+ if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")):
+ rows.append(({"name": "토층 두께"}, f"{span} · {zone['soil_thickness_m']:g} m"))
+ for i, (m, span) in enumerate(rows):
+ r = 26 + i
+ _copy_row(sheet, 26, r, "ABCDEFG")
+ _set(sheet, f"A{r}", m.get("name") or names.get(m.get("id"), ""))
+ _set(sheet, f"B{r}", span)
+ for col, field in zip("CDEF", ground_model.MATERIAL_FIELDS):
+ _set(sheet, f"{col}{r}", float(m[field]) if field in m else None)
+ base = defaults.get(m.get("id"))
+ same = base and all(
+ float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS
+ )
+ _set(
+ sheet, f"G{r}", ("문헌 추정치(기본값)" if same else "사용자 입력") if "c" in m else None
+ )
+
+
+def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
+ """s4 측점표 — 측점 하나 = 한 줄(6 행부터) · N.G 수."""
+ ground = inputs["ground"]
+ seismic = _seismic(ground)
+ labels = _labels(inputs["stations"])
+ rows = sorted(inputs["result2d"]["stations"], key=lambda s: float(s["chainage_m"]))
+ ng = 0
+ for i, st in enumerate(rows):
+ r = 6 + i
+ _copy_row(sheet, 6, r, "ABCDEFGHIJ")
+ side = st.get("side")
+ fs = st.get("fs_by_condition") or {}
+ _set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"])))
+ _set(sheet, f"B{r}", float(st["chainage_m"]))
+ _set(sheet, f"C{r}", SIDE_NAMES.get(side, side))
+ for col, crit, case in (("D", "G", "dry"), ("E", "H", "wet"), ("F", "I", "seismic")):
+ use = case != "seismic" or seismic
+ value = fs.get(case) if use else None
+ _set(sheet, f"{col}{r}", float(value) if value is not None else None)
+ _set(sheet, f"{crit}{r}", _criterion(ground, case, side) if use else None)
+ verdict = st.get("verdict")
+ ok = None if verdict not in ("ok", "ng") else verdict == "ok"
+ ng += ok is False
+ _verdict(sheet, f"J{r}", ok)
+ return ng
+
+
+def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str):
+ """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이."""
+ best = None
+ for case in (result or {}).get("cases") or []:
+ if case.get("condition") != condition:
+ continue
+ if side in ("cut", "fill") and case.get("role") not in (side, None):
+ continue
+ methods = (case.get("result") or {}).get("methods") or {}
+ found = (methods.get(method) or {}).get("min") or next(
+ (v["min"] for v in methods.values() if v.get("min")), None
+ )
+ if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]):
+ best = (found, case)
+ return best
+
+
+def _block_copy(sheet: dict[str, Any], template: dict[str, Any], k: int) -> None:
+ """양식 1~28 행 덩이를 k 번째 자리로(칸 · 병합 · 행 높이 · 조건부서식)."""
+ dr = BLOCK * k
+ for a1, cell in template["칸"].items():
+ if int(A1.match(a1).group(2)) <= BLOCK:
+ sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
+ for m in template.get("병합", []):
+ sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
+ for r, info in (template.get("행") or {}).items():
+ if int(r) <= BLOCK:
+ sheet["행"][str(int(r) + dr)] = dict(info)
+ for rule in template.get("조건부서식", []):
+ moved = copy.deepcopy(rule)
+ moved["id"] = f"{rule['id']}_{k}"
+ moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]]
+ sheet["조건부서식"].append(moved)
+
+
+CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
+
+
+def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
+ """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)]."""
+ template = copy.deepcopy(sheet)
+ reps = inputs["representatives"]
+ ground = inputs["ground"]
+ labels = _labels(inputs["stations"])
+ sheet["칸"] = {}
+ sheet["병합"], sheet["행"], sheet["조건부서식"], sheet["그림"] = [], {}, [], []
+ if not reps:
+ _block_copy(sheet, template, 0)
+ _set(sheet, "A1", "대표 단면 — 해당 없음")
+ sheet["인쇄"]["쪽나눔"] = []
+ return []
+ jobs: list[tuple[str, str]] = []
+ for k, rep in enumerate(reps):
+ dr = BLOCK * k
+ _block_copy(sheet, template, k)
+ st = rep["summary"]
+ side = st.get("side")
+ method = st.get("method") or "bishop"
+ label = _label_at(labels, float(st["chainage_m"]))
+ picks = {c: _case_slip(rep.get("result"), c, side, method) for c in CASES}
+ dry = picks.get("dry")
+ height = float(dry[1].get("height_m") or 0) if dry else 0.0
+ mark = CIRCLED[k] if k < len(CIRCLED) else str(k + 1)
+ _set(
+ sheet,
+ f"A{1 + dr}",
+ f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m",
+ )
+ reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
+ if rep.get("reason") == "lowest_fs":
+ reason = f"{reason} {LOWEST_N}"
+ _set(sheet, f"B{2 + dr}", reason)
+ key = fig_key(rep["station_key"])
+ sheet["그림"].append(
+ {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"}
+ )
+ fs = st.get("fs_by_condition") or {}
+ slips = []
+ for r, case in zip((24, 25, 26), CASES):
+ row = r + dr
+ if case == "seismic" and not _seismic(ground):
+ continue
+ value = fs.get(case)
+ crit = _criterion(ground, case, side)
+ pick = picks.get(case)
+ _set(sheet, f"B{row}", METHOD_NAMES.get(method, method))
+ _set(sheet, f"C{row}", float(value) if value is not None else None)
+ _set(sheet, f"D{row}", crit)
+ _verdict(sheet, f"E{row}", None if value is None else float(value) >= crit - 1e-9)
+ slip = pick[0] if pick else None
+ center = (slip or {}).get("center")
+ _set(sheet, f"F{row}", float(slip["radius"]) if slip and slip.get("radius") else None)
+ _set(sheet, f"G{row}", float(center[0]) if center else None)
+ _set(sheet, f"H{row}", float(center[1]) if center else None)
+ if slip and slip.get("points"):
+ slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]})
+ design = (rep.get("cross") or {}).get("design") or {}
+ kind = (dry[0].get("kind") if dry else None) or ""
+ layers = rep.get("layers") or {}
+ note = [
+ {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""),
+ f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}",
+ f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "",
+ ]
+ _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n))
+ jobs.append((key, _section_figure(rep, slips, f"{label} — {reason}")))
+ sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))]
+ return jobs
+
+
+def _section_figure(rep: dict[str, Any], slips: list[dict[str, Any]], title: str) -> str:
+ cross = rep.get("cross") or {}
+ design = cross.get("design") or {}
+ ground = sorted(
+ (float(s["offset_m"]), float(s["z"]))
+ for s in cross.get("samples") or []
+ if s.get("valid", True) and s.get("z") is not None
+ )
+ line = sorted(
+ (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
+ )
+ surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground
+ thickness = (rep.get("layers") or {}).get("soil_thickness_m")
+ rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None
+ edges = design.get("road_edges") or {}
+ road = None
+ if edges.get("left") and edges.get("right"):
+ road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"]))
+ heights = []
+ if road and line:
+ for case in (rep.get("result") or {}).get("cases") or []:
+ if case.get("condition") != "dry" or not case.get("height_m"):
+ continue
+ left = case.get("side") == "left"
+ edge = road[1] if left else road[0]
+ day = line[-1][0] if left else line[0][0]
+ z0, z1 = figure._interp(surface, edge), figure._interp(surface, day)
+ if z0 is not None and z1 is not None:
+ heights.append({"x": day, "z0": z0, "z1": z1})
+ return figure.section_svg(
+ title=title,
+ ground=ground,
+ surface=surface,
+ rock=rock,
+ slips=slips,
+ road=road,
+ heights=heights,
+ wet=any(s["condition"] == "wet" for s in slips),
+ )
+
+
+def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
+ """s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감."""
+ ground = inputs["ground"]
+ labels = _labels(inputs["stations"])
+ rows2d = inputs["result2d"]["stations"]
+ crit = _criterion(ground, "dry")
+ sections = []
+ for i, sec in enumerate(inputs["sections3d"]):
+ r = 20 + i
+ _copy_row(sheet, 20, r, "ABCDEGH")
+ start, end = float(sec["start_m"]), float(sec["end_m"])
+ inside = [
+ float(s["fs_min"])
+ for s in rows2d
+ if s.get("fs_min") is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6
+ ]
+ f3 = float(sec["f_min"])
+ ok = f3 >= crit - 1e-9
+ _set(sheet, f"A{r}", f"{i + 1}구간" + (" (미리보기)" if sec.get("preview") else ""))
+ _set(sheet, f"B{r}", _label_at(labels, start))
+ _set(sheet, f"C{r}", _label_at(labels, end))
+ _set(sheet, f"D{r}", f3)
+ _set(sheet, f"E{r}", min(inside) if inside else None)
+ sheet["칸"][f"F{r}"] = {
+ "식": f'IF(E{r}>0,D{r}/E{r},"")',
+ "서식": sheet["칸"]["F20"]["서식"],
+ }
+ _set(sheet, f"G{r}", float(sec.get("volume") or 0))
+ _verdict(sheet, f"H{r}", ok)
+ sections.append({"start_m": start, "end_m": end, "ok": ok})
+ if not inputs["sections3d"]:
+ _set(sheet, "A20", "3D 해석 전")
+ xy = {round(float(s["chainage_m"]), 3): s for s in inputs["stations"]}
+ marks = []
+ for st in sorted(rows2d, key=lambda s: float(s["chainage_m"])):
+ at = xy.get(round(float(st["chainage_m"]), 3))
+ if at and at.get("center_x") is not None:
+ verdict = st.get("verdict")
+ marks.append(
+ {
+ "x": float(at["center_x"]),
+ "y": float(at["center_y"]),
+ "ok": None if verdict not in ("ok", "ng") else verdict == "ok",
+ "label": at.get("label"),
+ }
+ )
+ line = [
+ (float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"]))
+ for p in inputs["route_points"]
+ ]
+ svg = figure.band_svg(
+ title="평면 위험 띠 — 2D 측점 판정 · 3D 구간", line=line, stations=marks, sections=sections
+ )
+ return BAND_KEY, svg
+
+
+def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
+ """양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감."""
+ by = {s["id"]: s for s in book["시트"]}
+ fill_conditions(by["s3"], inputs)
+ fill_stations(by["s4"], inputs)
+ jobs = fill_sections(by["s5"], inputs)
+ jobs.append(fill_3d(by["s6"], inputs))
+ return jobs
+
+
+def sheet_sign(sheet: dict[str, Any]) -> str:
+ """시트 서명 — 칸 값 · 식 · 서식 · 병합 · 그림 · 행열 크기
+ (빈 칸 · 빈 꾸밈은 뺌) · 덮임 알림용."""
+ cells = {
+ a1: [c.get("값"), c.get("식"), c.get("서식", 0)]
+ for a1, c in (sheet.get("칸") or {}).items()
+ if c.get("값") is not None or c.get("식") or c.get("서식")
+ }
+ body = {
+ "칸": cells,
+ "병합": sorted(sheet.get("병합") or []),
+ "그림": sheet.get("그림") or [],
+ "행": {k: v for k, v in (sheet.get("행") or {}).items() if v},
+ "열": {k: v for k, v in (sheet.get("열") or {}).items() if v},
+ }
+ text = json.dumps(body, ensure_ascii=False, sort_keys=True)
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()
diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py
new file mode 100644
index 000000000..9f39a4a91
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Figure.py
@@ -0,0 +1,299 @@
+"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
+
+ section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 ·
+ 우기 지하수위 ·
+ 조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금
+ band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠
+
+좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset
+(B06 횡단도 관례 · `B06_Section_UI_Cross_View.ts`).
+값 셈 없음 — 해석 결과(E03 · E04 저장본)를 그리기만.
+"""
+
+from __future__ import annotations
+
+import math
+from html import escape
+from typing import Any
+
+SECTION_W, SECTION_H = 712, 533
+BAND_W, BAND_H = 684, 320
+OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
+CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
+CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
+FONT = "font-family='Malgun Gothic, sans-serif'"
+
+
+def _nice_step(span: float, count: int) -> float:
+ raw = max(span, 1e-6) / max(count, 1)
+ base = 10 ** math.floor(math.log10(raw))
+ return next(m * base for m in (1, 2, 5, 10) if m * base >= raw)
+
+
+def _poly(points: list[tuple[float, float]], **attrs: str) -> str:
+ if len(points) < 2:
+ return ""
+ d = " ".join(f"{x:.1f},{y:.1f}" for x, y in points)
+ extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
+ return f""
+
+
+def _text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", **attrs) -> str:
+ extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
+ return (
+ f"{escape(text)}"
+ )
+
+
+def _interp(line: list[tuple[float, float]], x: float) -> float | None:
+ for (x0, z0), (x1, z1) in zip(line, line[1:]):
+ if min(x0, x1) - 1e-9 <= x <= max(x0, x1) + 1e-9:
+ return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0)
+ return None
+
+
+def section_svg(
+ *,
+ title: str,
+ ground: list[tuple[float, float]],
+ surface: list[tuple[float, float]],
+ rock: list[tuple[float, float]] | None,
+ slips: list[dict[str, Any]],
+ road: tuple[float, float] | None,
+ heights: list[dict[str, Any]],
+ wet: bool,
+) -> str:
+ """대표 단면 SVG.
+
+ ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
+ road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수.
+ """
+ pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64
+ pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
+ if not pts:
+ return _empty(SECTION_W, SECTION_H, title)
+ xs = [p[0] for p in pts]
+ zs = [p[1] for p in pts]
+ x_lo, x_hi = min(xs), max(xs)
+ z_lo, z_hi = min(zs), max(zs)
+ z_lo -= 0.08 * (z_hi - z_lo + 1)
+ z_hi += 0.12 * (z_hi - z_lo + 1)
+ w, h = SECTION_W - pad_l - pad_r, SECTION_H - pad_t - pad_b
+ scale = min(w / max(x_hi - x_lo, 1e-6), h / max(z_hi - z_lo, 1e-6)) # 가로 · 세로 같은 축척
+ cx = pad_l + (w - (x_hi - x_lo) * scale) / 2
+ cz = pad_t + (h - (z_hi - z_lo) * scale) / 2
+
+ def sx(x: float) -> float:
+ return cx + (x_hi - x) * scale # +offset = 화면 왼쪽
+
+ def sz(z: float) -> float:
+ return cz + (z_hi - z) * scale
+
+ def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]:
+ return [(sx(x), sz(z)) for x, z in line]
+
+ out = [
+ f"")
+ return "".join(out)
+
+
+def band_svg(
+ *,
+ title: str,
+ line: list[tuple[float, float, float]],
+ stations: list[dict[str, Any]],
+ sections: list[dict[str, Any]],
+) -> str:
+ """평면 위험 띠 SVG.
+
+ line = [(x, y, 누가)] 노선 · stations = [{x, y, ok: bool | None, label}] 2D 판정 ·
+ sections = [{start_m, end_m, ok: bool}] 3D 구간(노선 위 굵은 띠).
+ """
+ pad = 24
+ xy = [(p[0], p[1]) for p in line] or [(s["x"], s["y"]) for s in stations]
+ if not xy:
+ return _empty(BAND_W, BAND_H, title)
+ xs = [p[0] for p in xy]
+ ys = [p[1] for p in xy]
+ w, h = BAND_W - 2 * pad, BAND_H - 2 * pad - 40
+ scale = min(w / max(max(xs) - min(xs), 1e-6), h / max(max(ys) - min(ys), 1e-6))
+ ox = pad + (w - (max(xs) - min(xs)) * scale) / 2
+ oy = pad + 22 + (h - (max(ys) - min(ys)) * scale) / 2
+
+ def sp(x: float, y: float) -> tuple[float, float]:
+ return ox + (x - min(xs)) * scale, oy + (max(ys) - y) * scale # 북 = 위
+
+ out = [
+ f"")
+ return "".join(out)
+
+
+def _empty(width: int, height: int, title: str) -> str:
+ return (
+ f""
+ )
diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py
new file mode 100644
index 000000000..9132c1d7a
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Router.py
@@ -0,0 +1,241 @@
+"""E05 안정성평가 보고서 API (요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
+
+- `GET /api/projects/{id}/stab/report` — 작업본(없고 2D 결과가 있으면 처음 만듦 + 초기값) · 판 ·
+ 해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지
+- `PUT …/stab/report` — [저장] `{rev, book}` · 판이 다르면 409 · 서버 재계산(`with_server_values`)
+- `POST …/stab/report/refresh` — [보고서 갱신] `{rev}` · 프로그램 몫 시트 · 그림만 다시 ·
+ 덮인 시트에 사용자 고침이 있었으면 `overwritten` + 옛 판 `_지난/`
+- `POST …/stab/report/reset` — [초기화] 초기값을 작업본에 덮어씀
+- `POST …/stab/report/xlsx` · `…/pdf` `{book}` — 화면 지금 책을 서버 재계산 → xlsx · PDF(저장 안 함)
+- `GET …/stab/report/xlsx` · `…/pdf` — 작업본으로(보기만 허용된 회사도)
+- `GET …/stab/report/fig?key=` — 그림 PNG
+
+계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`
+(지정받은 회사 = GET 만 · 그 밖 403).
+"""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+from uuid import UUID
+
+import aiomysql
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import FileResponse, Response
+from pydantic import BaseModel
+
+from common_util.common_util_auth import verify_session
+from common_util.common_util_print_pdf import PrintError
+from common_util.common_util_spreadsheet import RecalcError, with_server_values, workbook_pdf
+from common_util.common_util_storage import resolve_stored_project_path
+from common_util.common_util_workbook_xlsx import workbook_to_xlsx
+from config.config_db import get_db_pool
+from E05_StabReport import E05_StabReport_Build as build
+from E05_StabReport import E05_StabReport_Store as store
+
+router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"])
+XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+TITLE = "안정성평가_보고서"
+_LOCKS: dict[str, asyncio.Lock] = {}
+
+
+class SaveRequest(BaseModel):
+ rev: str | None = None
+ book: dict[str, Any]
+
+
+class RevRequest(BaseModel):
+ rev: str | None = None
+
+
+class BookRequest(BaseModel):
+ book: dict[str, Any]
+
+
+def _lock(project_id: UUID) -> asyncio.Lock:
+ return _LOCKS.setdefault(str(project_id), asyncio.Lock())
+
+
+async def _project(project_id: UUID) -> tuple[Path, int | None]:
+ async with (
+ get_db_pool().acquire() as connection,
+ connection.cursor(aiomysql.DictCursor) as cursor,
+ ):
+ await cursor.execute(
+ "SELECT storage_path, company_id FROM projects"
+ " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL",
+ (str(project_id),),
+ )
+ row = await cursor.fetchone()
+ if not row or not row["storage_path"]:
+ raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.")
+ return Path(resolve_stored_project_path(row["storage_path"])), row["company_id"]
+
+
+def _editable(session: dict[str, Any], company_id: int | None) -> bool:
+ return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == company_id
+
+
+def _check_book(book: dict[str, Any]) -> None:
+ sheets = book.get("시트")
+ if book.get("종류") != "통합문서" or not isinstance(sheets, list) or not sheets:
+ raise HTTPException(status_code=400, detail="통합문서 꼴이 아닙니다.")
+
+
+def _state(root: Path) -> dict[str, Any]:
+ book, meta = store.load(root)
+ return {
+ "book": book,
+ "rev": store.revision(root),
+ "meta": meta,
+ "stale": bool(meta) and meta.get("fingerprint") != build.analysis_fingerprint(root),
+ "has_initial": (build.initial_dir(root) / build.REPORT).is_file(),
+ }
+
+
+def _ensure(root: Path) -> dict[str, Any]:
+ """작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭)."""
+ if store.load(root)[0] is None:
+ try:
+ store.create(root)
+ except build.MissingInput as error:
+ return {"book": None, "reason": str(error)}
+ return _state(root)
+
+
+def _recalc_errors(error: Exception) -> HTTPException:
+ if isinstance(error, PrintError):
+ return HTTPException(status_code=503, detail=str(error))
+ return HTTPException(status_code=422, detail=str(error))
+
+
+@router.get("/{project_id}/stab/report", response_model=None)
+async def get_report(
+ project_id: UUID, session: dict[str, Any] = Depends(verify_session)
+) -> dict[str, Any]:
+ root, company_id = await _project(project_id)
+ async with _lock(project_id):
+ try:
+ state = await asyncio.to_thread(_ensure, root)
+ except (RecalcError, PrintError) as error:
+ raise _recalc_errors(error) from error
+ return {"status": "success", "editable": _editable(session, company_id), **state}
+
+
+@router.put("/{project_id}/stab/report", response_model=None)
+async def put_report(project_id: UUID, payload: SaveRequest) -> dict[str, Any]:
+ root, _ = await _project(project_id)
+ _check_book(payload.book)
+ async with _lock(project_id):
+ if store.load(root)[0] is None:
+ raise HTTPException(status_code=409, detail="보고서가 아직 없습니다.")
+ if payload.rev != store.revision(root):
+ raise HTTPException(
+ status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
+ )
+ try:
+ await asyncio.to_thread(build.save, root, payload.book)
+ except RecalcError as error:
+ raise _recalc_errors(error) from error
+ return {"status": "success", **await asyncio.to_thread(_state, root)}
+
+
+@router.post("/{project_id}/stab/report/refresh", response_model=None)
+async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any]:
+ root, _ = await _project(project_id)
+ async with _lock(project_id):
+ current = store.revision(root)
+ if current is not None and payload.rev != current:
+ raise HTTPException(
+ status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
+ )
+ try:
+ result = await asyncio.to_thread(build.refresh, root)
+ except build.MissingInput as error:
+ raise HTTPException(status_code=409, detail=str(error)) from error
+ except (RecalcError, PrintError) as error:
+ raise _recalc_errors(error) from error
+ return {
+ "status": "success",
+ "overwritten": result["overwritten"],
+ **await asyncio.to_thread(_state, root),
+ }
+
+
+@router.post("/{project_id}/stab/report/reset", response_model=None)
+async def reset_report(project_id: UUID) -> dict[str, Any]:
+ root, _ = await _project(project_id)
+ async with _lock(project_id):
+ if not await asyncio.to_thread(build.reset, root):
+ raise HTTPException(status_code=404, detail="초기값이 없습니다.")
+ return {"status": "success", **await asyncio.to_thread(_state, root)}
+
+
+def _file(data: bytes, media: str, ext: str, inline: bool = False) -> Response:
+ kind = "inline" if inline else "attachment"
+ name = quote(f"{TITLE}.{ext}")
+ return Response(
+ content=data,
+ media_type=media,
+ headers={"Content-Disposition": f"{kind}; filename*=UTF-8''{name}"},
+ )
+
+
+def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes:
+ if book is None:
+ book = store.load(root)[0]
+ if book is None:
+ raise HTTPException(status_code=404, detail="보고서가 아직 없습니다.")
+ figures = store.figures_of(root, book)
+ if kind == "xlsx":
+ return workbook_to_xlsx(with_server_values(book), figures)
+ clean = dict(book)
+ clean.pop("계산값", None) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀)
+ return workbook_pdf(clean, figures)
+
+
+async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response:
+ root, _ = await _project(project_id)
+ if book is not None:
+ _check_book(book)
+ try:
+ data = await asyncio.to_thread(_export, root, book, kind)
+ except (RecalcError, PrintError) as error:
+ raise _recalc_errors(error) from error
+ if kind == "xlsx":
+ return _file(data, XLSX, "xlsx")
+ return _file(data, "application/pdf", "pdf", inline=True)
+
+
+@router.post("/{project_id}/stab/report/xlsx")
+async def post_xlsx(project_id: UUID, payload: BookRequest) -> Response:
+ return await _export_response(project_id, payload.book, "xlsx")
+
+
+@router.get("/{project_id}/stab/report/xlsx")
+async def get_xlsx(project_id: UUID) -> Response:
+ return await _export_response(project_id, None, "xlsx")
+
+
+@router.post("/{project_id}/stab/report/pdf")
+async def post_pdf(project_id: UUID, payload: BookRequest) -> Response:
+ return await _export_response(project_id, payload.book, "pdf")
+
+
+@router.get("/{project_id}/stab/report/pdf")
+async def get_pdf(project_id: UUID) -> Response:
+ return await _export_response(project_id, None, "pdf")
+
+
+@router.get("/{project_id}/stab/report/fig")
+async def get_figure(project_id: UUID, key: str) -> FileResponse:
+ root, _ = await _project(project_id)
+ if not key.startswith("fig/") or ".." in key:
+ raise HTTPException(status_code=400, detail="그림 열쇠가 틀렸습니다.")
+ path = build.fig_path(build.stage_dir(root), key)
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="그림이 없습니다.")
+ return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py
new file mode 100644
index 000000000..ac96b8429
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Store.py
@@ -0,0 +1,159 @@
+"""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
diff --git a/E05_StabReport/E05_StabReport_UI_Page.ts b/E05_StabReport/E05_StabReport_UI_Page.ts
new file mode 100644
index 000000000..d700dd885
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_UI_Page.ts
@@ -0,0 +1,300 @@
+/* =============================================================================
+ * E05_StabReport_UI_Page.ts
+ * E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow).
+ * GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale.
+ * 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
+ * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
+ * [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
+ * [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
+ * ========================================================================== */
+
+import "./E05_StabReport_UI_Style.css";
+import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
+import {
+ createButton,
+ el,
+ hideLoadingOverlay,
+ showConfirmDialog,
+ showLoadingOverlay,
+ showToast,
+} from "@ui/ui_template_elements";
+import { t } from "@ui/ui_template_locale";
+import { mountGroupStep } from "../A00_Common/group_workflow";
+import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
+import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
+
+interface ReportMeta {
+ fingerprint: string;
+ built_at: string;
+ figures: string[];
+}
+
+interface ReportResponse {
+ book: Workbook | null;
+ rev?: string | null;
+ meta?: ReportMeta | null;
+ stale?: boolean;
+ has_initial?: boolean;
+ editable?: boolean;
+ reason?: string;
+ overwritten?: string[];
+}
+
+interface CacheEntry {
+ rev: string;
+ book: Workbook;
+}
+
+const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`;
+
+async function failure(response: Response): Promise {
+ const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
+ return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
+}
+
+async function request(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ ...init,
+ });
+ if (!response.ok) throw await failure(response);
+ return (await response.json()) as ReportResponse;
+}
+
+function download(blob: Blob, name: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+export function renderE05StabReport(root: HTMLElement): void {
+ const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
+ const main = el("div", { className: "e05-page" });
+ const staleBar = el("div", { className: "e05-stale" });
+ staleBar.hidden = true;
+ const sheetHost = el("div", { className: "e05-sheet" });
+ main.append(staleBar, sheetHost);
+ const status = el("p", { className: "e05-status" });
+
+ const button = (label: string, variant: "filled" | "ghost" | "danger-ghost", run: () => void) =>
+ createButton({ label, variant, onClick: run });
+ const saveButton = button(t("Common_Btn_Save"), "filled", () => void save());
+ const resetButton = button(t("Common_Btn_Reset"), "danger-ghost", () => void reset());
+ const refreshButton = button(t("E05_Btn_Refresh"), "ghost", () => void refresh());
+ const xlsxButton = button(t("E05_Btn_Xlsx"), "ghost", () => void exportFile("xlsx"));
+ const pdfButton = button(t("E05_Btn_Pdf"), "ghost", () => void exportFile("pdf"));
+ const previewButton = button(t("E05_Btn_Preview"), "ghost", () => void preview());
+ const staleButton = button(t("E05_Btn_Refresh"), "filled", () => void refresh());
+ staleBar.append(el("span", { text: `${t("E05_Stale")} —` }), staleButton);
+ const all = [saveButton, resetButton, refreshButton, xlsxButton, pdfButton, previewButton];
+ for (const b of all) b.disabled = true; // 읽어 온 뒤 켬
+
+ const exports = el("div", { className: "ui-sidebar-actions" });
+ exports.append(xlsxButton, pdfButton, previewButton);
+ const actions = el("div", { className: "ui-sidebar-actions" });
+ actions.append(refreshButton, resetButton, saveButton);
+ const panel = el("div", { className: "ui-sidebar-panel e05-panel" });
+ panel.append(el("p", { className: "e05-hint", text: t("E05_Note") }), status, exports, actions);
+
+ const frame = document.createElement("iframe");
+ frame.className = "e05-preview__frame";
+ frame.title = t("E05_Btn_Preview");
+ const previewBox = el("div", { className: "e05-preview" });
+ previewBox.hidden = true;
+ let previewUrl: string | null = null;
+ const closePreview = (): void => {
+ previewBox.hidden = true;
+ frame.removeAttribute("src");
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
+ previewUrl = null;
+ };
+ const head = el("div", { className: "e05-preview__head" });
+ head.append(button(t("Common_Btn_Close"), "ghost", closePreview));
+ previewBox.append(head, frame);
+ main.append(previewBox);
+
+ mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel });
+ if (!projectId)
+ return void sheetHost.append(el("p", { className: "e05-empty", text: t("E05_NoProject") }));
+
+ let sheet: SpreadsheetHandle | null = null;
+ let state: ReportResponse | null = null;
+ let editable = false;
+ let dirty = false;
+ let busy = false;
+
+ const paint = (): void => {
+ const lines: string[] = [];
+ if (!editable) lines.push(t("E05_ReadOnly"));
+ if (busy) lines.push(t("E05_Working"));
+ if (dirty) lines.push(t("E05_Unsaved"));
+ if (state?.meta?.built_at)
+ lines.push(`${t("E05_BuiltAt")} ${state.meta.built_at.replace("T", " ")}`);
+ status.textContent = lines.join("\n");
+ const ready = Boolean(state?.book) && !busy;
+ saveButton.disabled = !ready || !editable || !dirty;
+ resetButton.disabled = !ready || !editable || !state?.has_initial;
+ refreshButton.disabled = staleButton.disabled = !ready || !editable;
+ xlsxButton.disabled = pdfButton.disabled = previewButton.disabled = !ready;
+ staleBar.hidden = !state?.stale || !editable;
+ };
+
+ const figureSrc = (key: string): string =>
+ `${API_BASE_URL}/projects/${projectId}/stab/report/fig?key=${encodeURIComponent(key)}` +
+ `&v=${encodeURIComponent(state?.meta?.built_at ?? "")}`;
+
+ /** 서버 응답 받음 — keepCache 면 같은 판 캐시를 살림 */
+ const adopt = async (body: ReportResponse, keepCache: boolean): Promise => {
+ state = body;
+ if (body.editable !== undefined) editable = body.editable;
+ if (!body.book) {
+ sheet?.destroy();
+ sheet = null;
+ sheetHost.replaceChildren(el("p", { className: "e05-empty", text: body.reason ?? "" }));
+ dirty = false;
+ return paint();
+ }
+ let book = body.book;
+ const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
+ const cached = raw ? (JSON.parse(raw) as CacheEntry) : null;
+ if (cached && cached.rev === body.rev) book = cached.book;
+ else sessionStorage.removeItem(cacheKey(projectId));
+ dirty = Boolean(cached && cached.rev === body.rev);
+ const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
+ sheet?.destroy();
+ sheetHost.replaceChildren();
+ sheet = createSpreadsheet(sheetHost, book, {
+ tabs: true,
+ readOnly: !editable,
+ figureSrc,
+ onChange: (next) => {
+ dirty = true;
+ sessionStorage.setItem(
+ cacheKey(projectId),
+ JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry),
+ );
+ paint();
+ },
+ });
+ paint();
+ };
+
+ const run = async (task: () => Promise, overlay = false): Promise => {
+ busy = true;
+ paint();
+ if (overlay) showLoadingOverlay();
+ try {
+ await task();
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
+ } finally {
+ if (overlay) hideLoadingOverlay();
+ busy = false;
+ paint();
+ }
+ };
+
+ const putBook = async (): Promise => {
+ if (!sheet || !state) return;
+ const body = await request(`/projects/${projectId}/stab/report`, {
+ method: "PUT",
+ body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }),
+ });
+ sessionStorage.removeItem(cacheKey(projectId));
+ state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale };
+ dirty = false;
+ };
+
+ async function save(): Promise {
+ if (!editable || !dirty) return;
+ await run(async () => {
+ await putBook();
+ showToast(t("E05_Saved"), "success");
+ }, true);
+ }
+
+ async function refresh(): Promise {
+ if (!editable || !state) return;
+ if (
+ dirty &&
+ !(await showConfirmDialog(t("E05_SaveFirst"), t("Common_Btn_Save"), t("Common_Btn_Cancel")))
+ )
+ return;
+ await run(async () => {
+ if (dirty) await putBook();
+ const body = await request(`/projects/${projectId}/stab/report/refresh`, {
+ method: "POST",
+ body: JSON.stringify({ rev: state?.rev }),
+ });
+ await adopt({ ...body, editable }, false);
+ const over = body.overwritten ?? [];
+ if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
+ else showToast(t("E05_Refreshed"), "success");
+ });
+ }
+
+ async function reset(): Promise {
+ if (!editable) return;
+ if (
+ !(await showConfirmDialog(
+ t("E05_ResetConfirm"),
+ t("Common_Btn_Reset"),
+ t("Common_Btn_Cancel"),
+ ))
+ )
+ return;
+ await run(async () => {
+ const body = await request(`/projects/${projectId}/stab/report/reset`, { method: "POST" });
+ await adopt({ ...body, editable }, false);
+ showToast(t("E05_ResetDone"), "success");
+ }, true);
+ }
+
+ /** 화면 지금 책(고칠 수 있으면) 또는 작업본(보기만) → 서버가 다시 풀어 만든 파일 */
+ async function fetchFile(kind: "xlsx" | "pdf"): Promise {
+ const url = `${API_BASE_URL}/projects/${projectId}/stab/report/${kind}`;
+ const init: RequestInit =
+ editable && sheet
+ ? {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ book: sheet.getDoc() }),
+ }
+ : { credentials: "include" };
+ const response = await fetch(url, init);
+ if (!response.ok) throw await failure(response);
+ return response.blob();
+ }
+
+ async function exportFile(kind: "xlsx" | "pdf"): Promise {
+ await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`));
+ }
+
+ async function preview(): Promise {
+ await run(async () => {
+ const blob = await fetchFile("pdf");
+ closePreview();
+ previewUrl = URL.createObjectURL(blob);
+ frame.src = previewUrl;
+ previewBox.hidden = false;
+ });
+ }
+
+ void run(async () => {
+ try {
+ await adopt(await request(`/projects/${projectId}/stab/report`), true);
+ } catch (error) {
+ sheetHost.replaceChildren(
+ el("p", {
+ className: "e05-empty",
+ text: error instanceof Error ? error.message : t("Common_Status_Error"),
+ }),
+ );
+ }
+ });
+}
diff --git a/E05_StabReport/E05_StabReport_UI_Style.css b/E05_StabReport/E05_StabReport_UI_Style.css
new file mode 100644
index 000000000..f958c575c
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_UI_Style.css
@@ -0,0 +1,81 @@
+/* =============================================================================
+ * E05_StabReport_UI_Style.css
+ * E05 보고서 본문 — 위 띠 · 내장 엑셀 · PDF 미리보기 창. 색은 테마 토큰만(밝음 · 어두움 같이)
+ * ========================================================================== */
+
+.e05-page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ box-sizing: border-box;
+}
+
+.e05-stale {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-12);
+ padding: var(--spacing-8) var(--spacing-16);
+ background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
+ color: var(--color-text);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.e05-stale[hidden] {
+ display: none;
+}
+
+.e05-sheet {
+ flex: 1;
+ min-height: 0;
+}
+
+.e05-empty,
+.e05-status,
+.e05-hint {
+ margin: 0;
+ white-space: pre-line;
+ color: var(--color-text-muted);
+}
+
+.e05-empty {
+ padding: var(--spacing-16);
+}
+
+.e05-status.is-error {
+ color: var(--color-danger);
+}
+
+.e05-panel .ui-sidebar-actions {
+ flex-wrap: wrap;
+}
+
+.e05-preview {
+ position: fixed;
+ inset: 4vh 4vw;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-8);
+ padding: var(--spacing-12);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-raised);
+ box-shadow: 0 8px 32px rgb(0 0 0 / 35%);
+}
+
+.e05-preview[hidden] {
+ display: none;
+}
+
+.e05-preview__head {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--spacing-8);
+}
+
+.e05-preview__frame {
+ flex: 1;
+ width: 100%;
+ border: 0;
+ background: #fff;
+}
diff --git a/E05_StabReport/draft/E05_StabReport_Route.ts b/E05_StabReport/draft/E05_StabReport_Route.ts
index 62a5a5bb7..fb762bad2 100644
--- a/E05_StabReport/draft/E05_StabReport_Route.ts
+++ b/E05_StabReport/draft/E05_StabReport_Route.ts
@@ -1,7 +1,7 @@
/* =============================================================================
* E05_StabReport_Route.ts
- * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
- * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
+ * 길 선언(PLAN 529-1 · 534-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
+ * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E05 보고서 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
workflow: true,
project: true,
footerless: true,
- load: async () =>
- (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E05_STAB_REPORT),
+ load: async () => (await import("../E05_StabReport_UI_Page")).renderE05StabReport,
};
diff --git a/main.py b/main.py
index 8a98ea188..852f378ed 100644
--- a/main.py
+++ b/main.py
@@ -82,6 +82,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_ro
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
+from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -490,6 +491,7 @@ app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
+app.include_router(e05_stab_report_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py
new file mode 100644
index 000000000..d46f0b3e5
--- /dev/null
+++ b/resources/tester/test_534_3_e05_report.py
@@ -0,0 +1,296 @@
+"""534-3(빌더 · storage) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store`.
+
+가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로
+측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · xlsx 되읽기 · PDF 쪽 수.
+"""
+
+import io
+import json
+import shutil
+from pathlib import Path
+
+import pytest
+from openpyxl import load_workbook
+
+from common_util.common_util_print_pdf import PrintError
+from common_util.common_util_spreadsheet import workbook_pdf
+from common_util.common_util_workbook_xlsx import workbook_to_xlsx
+from E02_StabGround import E02_StabGround_Model as ground_model
+from E05_StabReport import E05_StabReport_Build as build
+from E05_StabReport import E05_StabReport_Figure as figure
+from E05_StabReport import E05_StabReport_Store as store
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
+N = 30 # 측점 수
+NG_AT = {3, 10, 17, 24} # N.G 측점
+REPS = [("grid:3", "lowest_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")]
+
+
+def _key(i: int) -> str:
+ return f"grid:{i}"
+
+
+def _summary(i: int, bump: float = 0.0) -> dict:
+ ng = i in NG_AT
+ dry = 1.31 if ng else 1.7 + i / 100 + bump
+ return {
+ "station_key": _key(i),
+ "chainage_m": 20.0 * i,
+ "side": "cut" if i % 2 else "fill",
+ "fs_min": dry - 0.2,
+ "fs_by_condition": {"dry": dry, "wet": dry - 0.2},
+ "method": "bishop",
+ "verdict": "ng" if ng else "ok",
+ }
+
+
+def _slip(F: float) -> dict:
+ pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)]
+ return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5}
+
+
+def _station_doc(i: int) -> dict:
+ s = _summary(i)
+ cases = []
+ for cond in ("dry", "wet"):
+ F = s["fs_by_condition"][cond]
+ for side, role in (("left", "cut"), ("right", "fill")):
+ cases.append(
+ {
+ "condition": cond,
+ "side": side,
+ "role": role,
+ "direction": -1 if side == "left" else 1,
+ "limits": [-10.0, 10.0],
+ "height_m": 6.0,
+ "min_F": {"bishop": F},
+ "result": {"methods": {"bishop": {"min": _slip(F)}}},
+ }
+ )
+ return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases}
+
+
+def _cross(chainage: float) -> dict:
+ samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)]
+ line = [
+ {"offset_m": 8.0, "elevation_m": 103.2},
+ {"offset_m": 2.0, "elevation_m": 100.0},
+ {"offset_m": -2.0, "elevation_m": 100.0},
+ {"offset_m": -6.0, "elevation_m": 97.6},
+ ]
+ return {
+ "chainage_m": chainage,
+ "label": f"No.{int(chainage // 20)}",
+ "samples": samples,
+ "design": {
+ "design_line": line,
+ "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
+ "ground_type": "ripping_rock",
+ },
+ }
+
+
+@pytest.fixture
+def project(tmp_path, monkeypatch) -> Path:
+ root = tmp_path / "e_project"
+ d2 = root / "E03_StabSection" / "_derived"
+ (d2 / "results").mkdir(parents=True)
+ (d2 / "result2d.json").write_text(
+ json.dumps(
+ {
+ "input_fingerprint": "fp-1",
+ "computed_at": "2026-10-25T10:00:00",
+ "stations": [_summary(i) for i in range(N)],
+ }
+ ),
+ encoding="utf-8",
+ )
+ for key, _ in REPS:
+ i = int(key.split(":")[1])
+ (d2 / "results" / f"grid_{i}.json").write_text(
+ json.dumps(_station_doc(i)), encoding="utf-8"
+ )
+ (root / "E03_StabSection" / "representative.json").write_text(
+ json.dumps(
+ {
+ "rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5},
+ "stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS]
+ + [{"station_key": _key(5), "reason": "user", "enabled": False}],
+ }
+ ),
+ encoding="utf-8",
+ )
+ ground = ground_model.pick(ground_model.load_defaults())
+ ground["stations"] = [
+ {"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5}
+ for i in range(N)
+ ]
+ ground["zones"] = [
+ {"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None}
+ ]
+ path = ground_model.ground_path(root)
+ path.parent.mkdir(parents=True)
+ path.write_text(json.dumps(ground), encoding="utf-8")
+ r3 = root / "E04_Stab3D" / "results"
+ r3.mkdir(parents=True)
+ (r3 / "0.00_200.00.json").write_text(
+ json.dumps(
+ {
+ "fingerprint": "3d-1",
+ "settings": {},
+ "summary": {"start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0},
+ }
+ ),
+ encoding="utf-8",
+ )
+ stations = [
+ {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i}
+ for i in range(N)
+ ]
+ db = {
+ "project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"},
+ "route_points": [
+ {"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i}
+ for i in range(N)
+ ],
+ }
+ monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db)
+ monkeypatch.setattr(build.source, "stations", lambda _root: stations)
+ monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch))
+ return root
+
+
+def _sheets(book: dict) -> dict:
+ return {s["이름"]: s for s in book["시트"]}
+
+
+def _create(root: Path) -> dict:
+ try:
+ return store.create(root)
+ except PrintError as error:
+ pytest.skip(f"브라우저 없음 — {error}")
+
+
+def test_처음_만듦_측점수_NG_그림(project):
+ meta = _create(project)
+ book, saved = store.load(project)
+ by = _sheets(book)
+ rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c]
+ rows = [a1 for a1 in rows if int(a1[1:]) >= 6]
+ assert len(rows) == N # 측점 수 = 줄 수
+ j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6]
+ assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT)
+ assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT)
+ figs = sorted((project / "E05_StabReport" / "fig").glob("*.png"))
+ assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY
+ assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
+ assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84]
+ assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상"
+ assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31
+ assert by["대표단면"]["칸"]["F24"]["값"] == 12.5
+ assert by["표지"]["칸"]["C8"]["값"] == "검증 임도"
+ vals = book["계산값"]["s2"]
+ assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))}
+ # 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄
+ s3 = by["입력조건"]["칸"]
+ assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층")
+ assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3
+ s6 = by["3D요약"]["칸"]
+ assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "N.G", "서식": build.ST_NG}
+ assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 최소
+ assert (build.initial_dir(project) / build.REPORT).is_file()
+ assert saved["fingerprint"] == build.analysis_fingerprint(project)
+
+
+def test_갱신_사용자몫_불변_덮임(project):
+ _create(project)
+ book, _ = store.load(project)
+ by = _sheets(book)
+ by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
+ by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15}
+ store.save(project, book)
+ out = store.refresh(project)
+ assert out["overwritten"] == [] # 프로그램 몫을 안 고침
+ book, _ = store.load(project)
+ by = _sheets(book)
+ by["측점표"]["칸"]["A6"]["값"] = "고친 측점"
+ store.save(project, book)
+ r2 = project / build.RESULT2D
+ doc = json.loads(r2.read_text(encoding="utf-8"))
+ doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0}
+ doc["input_fingerprint"] = "fp-2"
+ r2.write_text(json.dumps(doc), encoding="utf-8")
+ assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"]
+ try:
+ out = store.refresh(project)
+ except PrintError as error:
+ pytest.skip(f"브라우저 없음 — {error}")
+ assert out["overwritten"] == ["측점표"]
+ assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1
+ book, meta = store.load(project)
+ by = _sheets(book)
+ assert (
+ by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견"
+ )
+ assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀
+ assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감
+ assert meta["fingerprint"] == build.analysis_fingerprint(project)
+ assert store.reset(project)
+ book, _ = store.load(project)
+ assert (
+ "C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"]
+ )
+
+
+def test_3D_없음_2D_없음(project):
+ shutil.rmtree(project / "E04_Stab3D")
+ inputs = build.gather(project)
+ book = build.form()
+ build.build_program(book, inputs)
+ by = _sheets(book)
+ assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전"
+ assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전"
+ (project / build.RESULT2D).unlink()
+ with pytest.raises(build.MissingInput):
+ build.gather(project)
+
+
+def test_xlsx_되읽기_pdf_쪽수(project):
+ _create(project)
+ book, _ = store.load(project)
+ figs = store.figures_of(project, book)
+ assert len(figs) == len(REPS) + 1
+ wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
+ assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"]
+ assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1
+ assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84]
+ assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith(
+ "FFC7CE"
+ )
+ book.pop("계산값", None)
+ try:
+ pdf = workbook_pdf(book, figs)
+ except PrintError as error:
+ pytest.skip(f"브라우저 없음 — {error}")
+ from pypdf import PdfReader
+
+ texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages]
+ stab = [i for i, t in enumerate(texts) if "산정 안전율" in t]
+ assert stab and all("산정 안전율" in texts[i] for i in stab)
+ # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1
+ assert len(texts) == 3 + len(stab) + len(REPS) + 1
+
+
+def test_단면_그림_SVG():
+ svg = figure.section_svg(
+ title="No.3",
+ ground=[(-10.0, 96.0), (10.0, 104.0)],
+ surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)],
+ rock=[(-10.0, 94.5), (10.0, 102.5)],
+ slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}],
+ road=(-2.0, 2.0),
+ heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}],
+ wet=True,
+ )
+ assert svg.startswith("