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"", + f"", + _text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK), + ] + # 눈금(표고 · 거리) + step_z = _nice_step(z_hi - z_lo, 6) + z = math.ceil(z_lo / step_z) * step_z + while z <= z_hi: + y = sz(z) + out.append( + f"" + ) + out.append(_text(pad_l - 6, y + 4, f"{z:g}", 10, "end", fill="#666666")) + z += step_z + step_x = _nice_step(x_hi - x_lo, 8) + x = math.ceil(x_lo / step_x) * step_x + base_y = SECTION_H - pad_b + 14 + while x <= x_hi: + out.append(_text(sx(x), base_y, f"{x:g}", 10, fill="#666666")) + x += step_x + out.append(_text(pad_l - 6, pad_t - 8, "EL. (m)", 10, "end", fill="#666666")) + out.append( + _text(SECTION_W - pad_r, base_y + 14, "offset (m) · 좌 +", 10, "end", fill="#666666") + ) + # 지반 · 지층 + if ground: + bottom = sz(z_lo) + body = screen(ground) + out.append( + "" + ) + if rock: + rb = screen(rock) + out.append( + "" + ) + out.append(_poly(rb, stroke="#795548", stroke_width="1.2", stroke_dasharray="6 3")) + out.append(_poly(screen(ground), stroke="#8D6E63", stroke_width="1.4")) + out.append(_poly(screen(surface), stroke=INK, stroke_width="2")) + if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시 + for frac in (0.2, 0.8): + px, pz = surface[int(frac * (len(surface) - 1))] + a, b = sx(px), sz(pz) - 4 + out.append( + f"" + ) + # 임계 활동면 + for slip in slips: + color = CONDITION_COLORS.get(slip["condition"], NG) + out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2")) + # 치수 — 노폭 · 비탈 높이 + if road: + r_off, l_off = road + zr, zl = _interp(surface, r_off), _interp(surface, l_off) + if zr is not None and zl is not None: + y = min(sz(zr), sz(zl)) - 18 + a, b = sx(l_off), sx(r_off) + out.append( + f"" + ) + out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 10, fill=INK)) + for dim in heights: + if abs(dim["z1"] - dim["z0"]) < 0.05: + continue + a = sx(dim["x"]) + y0, y1 = sz(dim["z0"]), sz(dim["z1"]) + out.append( + f"" + ) + left = a < SECTION_W / 2 # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) + label = f"H {abs(dim['z1'] - dim['z0']):.2f} m" + out.append( + _text( + a - 4 if left else a + 4, + (y0 + y1) / 2, + label, + 10, + "end" if left else "start", + fill=INK, + ) + ) + out.insert( + 2, + "" + "", + ) + # 범례 + legend = [("#8D6E63", "원지반", ""), (INK, "공사 뒤 지표", "")] + if rock: + legend.append(("#795548", "암 경계", "6 3")) + for slip in slips: + name = CONDITION_NAMES.get(slip["condition"], slip["condition"]) + f = slip.get("F") + legend.append( + ( + CONDITION_COLORS.get(slip["condition"], NG), + f"{name} 임계면 Fs {f:.2f}" if f else name, + "", + ) + ) + if wet: + legend.append(("#1565C0", "▽ 우기 지하수위 = 지표", "")) + lx, ly = pad_l, SECTION_H - 22 + for color, label, dash in legend: + dash_attr = f" stroke-dasharray='{dash}'" if dash else "" + out.append( + f"" + ) + out.append(_text(lx + 22, ly + 4, label, 10, "start", fill=INK)) + lx += 34 + sum(10 if ord(ch) > 0x2000 else 6 for ch in label) # 한글 · 기호 폭 + out.append("") + 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"", + f"", + _text(BAND_W / 2, 18, title, 13, font_weight="bold", fill=INK), + ] + out.append(_poly([sp(x, y) for x, y in xy], stroke="#BDBDBD", stroke_width="3")) + for sec in sections: + part = [sp(x, y) for x, y, ch in line if sec["start_m"] - 1e-6 <= ch <= sec["end_m"] + 1e-6] + color = OK if sec["ok"] else NG + out.append(_poly(part, stroke=color, stroke_width="12", stroke_opacity="0.35")) + for st in stations: + a, b = sp(st["x"], st["y"]) + color = MUTED if st["ok"] is None else OK if st["ok"] else NG + out.append(f"") + for st in (stations[0], stations[-1]) if stations else (): + a, b = sp(st["x"], st["y"]) + out.append(_text(a, b - 7, st["label"] or "", 10, fill=INK)) + lx, ly = pad, BAND_H - 14 + for color, label, width in ( + (OK, "2D O.K", 0), + (NG, "2D N.G", 0), + (OK, "3D 구간 O.K", 12), + (NG, "3D 구간 N.G", 12), + ): + if width: + out.append( + f"" + ) + else: + out.append(f"") + out.append(_text(lx + 26, ly, label, 10, "start", fill=INK)) + lx += 120 + if not sections: + out.append(_text(BAND_W - pad, ly, "3D 해석 전", 10, "end", fill=MUTED)) + out.append("") + return "".join(out) + + +def _empty(width: int, height: int, title: str) -> str: + return ( + f"" + f"" + + _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED) + + "" + ) 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("= 4 diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index e0ade76ab..05c8531b7 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -31,6 +31,7 @@ import { ui_locales_m1 } from "./ui_template_locale_m1"; import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; +import { ui_locales_e05 } from "./ui_template_locale_e05"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -70,6 +71,7 @@ export const ui_locales = { ...ui_locales_m2, ...ui_locales_de, ...ui_locales_e, + ...ui_locales_e05, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_e05.ts b/ui_template/ui_template_locale_e05.ts new file mode 100644 index 000000000..836b265fd --- /dev/null +++ b/ui_template/ui_template_locale_e05.ts @@ -0,0 +1,41 @@ +/* ============================================================================= + * ui_template_locale_e05.ts + * 다국어 사전 — E05 안정성평가 보고서(요구 347-5 · PLAN 534-3) + * + * 신규 문구는 해당 섹션 최하단에 추가. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e05 = { + /* --- E05 보고서 --- */ + E05_Note: [ + "표지 · 개요는 사용자 몫 — [보고서 갱신] 때 그대로 둡니다. 입력조건 · 측점표 · 대표단면 · 3D요약은 해석 결과로 다시 씁니다.", + "Cover and summary are yours — kept on [Refresh report]. Conditions, stations, sections and 3D sheets are rewritten from results.", + ], + E05_Btn_Refresh: ["보고서 갱신", "Refresh report"], + E05_Btn_Xlsx: ["엑셀", "Excel"], + E05_Btn_Pdf: ["PDF", "PDF"], + E05_Btn_Preview: ["PDF 미리보기", "PDF preview"], + E05_Stale: ["해석 결과가 바뀜", "Analysis results changed"], + E05_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E05_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], + E05_Saved: ["저장했습니다.", "Saved."], + E05_Refreshed: ["보고서를 갱신했습니다.", "Report refreshed."], + E05_Overwritten: [ + "고친 내용이 덮인 시트 — 옛 판은 _지난/ 에 보관:", + "Sheets whose edits were overwritten — old copy kept in _지난/:", + ], + E05_SaveFirst: [ + "저장 안 한 고침이 있습니다. 저장한 뒤 갱신할까요?", + "There are unsaved edits. Save first, then refresh?", + ], + E05_ResetConfirm: [ + "처음 만든 보고서로 되돌립니다. 고친 내용 · 갱신이 모두 사라집니다.", + "Restore the report as first built. All edits and refreshes are lost.", + ], + E05_ResetDone: ["처음 만든 보고서로 되돌렸습니다.", "Restored to the first build."], + E05_Working: ["만드는 중…", "Building…"], + E05_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + E05_BuiltAt: ["보고서 만든 때", "Report built at"], +} as const satisfies Record;