Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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()
|
||||
@@ -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"<polyline points='{d}' fill='none' {extra}/>"
|
||||
|
||||
|
||||
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"<text x='{x:.1f}' y='{y:.1f}' font-size='{size}' text-anchor='{anchor}' {FONT} "
|
||||
f"{extra}>{escape(text)}</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"<svg xmlns='http://www.w3.org/2000/svg' width='{SECTION_W}' height='{SECTION_H}' "
|
||||
f"viewBox='0 0 {SECTION_W} {SECTION_H}'>",
|
||||
f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>",
|
||||
_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"<line x1='{pad_l}' y1='{y:.1f}' x2='{SECTION_W - pad_r}' y2='{y:.1f}' "
|
||||
"stroke='#EEEEEE' stroke-width='1'/>"
|
||||
)
|
||||
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(
|
||||
"<polygon points='"
|
||||
+ " ".join(
|
||||
f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], bottom), (body[0][0], bottom)]
|
||||
)
|
||||
+ "' fill='#F3E9D8' stroke='none'/>"
|
||||
)
|
||||
if rock:
|
||||
rb = screen(rock)
|
||||
out.append(
|
||||
"<polygon points='"
|
||||
+ " ".join(
|
||||
f"{a:.1f},{b:.1f}" for a, b in [*rb, (rb[-1][0], sz(z_lo)), (rb[0][0], sz(z_lo))]
|
||||
)
|
||||
+ "' fill='#D9D2C5' stroke='none'/>"
|
||||
)
|
||||
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"<polygon points='{a - 5:.1f},{b - 8:.1f} {a + 5:.1f},{b - 8:.1f} "
|
||||
f"{a:.1f},{b:.1f}' "
|
||||
"fill='none' stroke='#1565C0' stroke-width='1.2'/>"
|
||||
)
|
||||
# 임계 활동면
|
||||
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"<line x1='{a:.1f}' y1='{y:.1f}' x2='{b:.1f}' y2='{y:.1f}' stroke='{INK}' "
|
||||
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
|
||||
)
|
||||
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"<line x1='{a:.1f}' y1='{y0:.1f}' x2='{a:.1f}' y2='{y1:.1f}' stroke='{INK}' "
|
||||
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
|
||||
)
|
||||
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,
|
||||
"<defs><marker id='t' viewBox='0 0 4 10' refX='2' refY='5' markerWidth='4' "
|
||||
"markerHeight='10' orient='auto'><path d='M2 0 L2 10' stroke='#222' stroke-width='1'/>"
|
||||
"</marker></defs>",
|
||||
)
|
||||
# 범례
|
||||
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"<line x1='{lx}' y1='{ly}' x2='{lx + 18}' y2='{ly}' stroke='{color}' "
|
||||
f"stroke-width='2'{dash_attr}/>"
|
||||
)
|
||||
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("</svg>")
|
||||
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"<svg xmlns='http://www.w3.org/2000/svg' width='{BAND_W}' height='{BAND_H}' "
|
||||
f"viewBox='0 0 {BAND_W} {BAND_H}'>",
|
||||
f"<rect width='{BAND_W}' height='{BAND_H}' fill='#ffffff'/>",
|
||||
_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"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.2' fill='{color}'/>")
|
||||
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"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 20}' y2='{ly - 4}' stroke='{color}' "
|
||||
f"stroke-width='{width}' stroke-opacity='0.35'/>"
|
||||
)
|
||||
else:
|
||||
out.append(f"<circle cx='{lx + 10}' cy='{ly - 4}' r='3.2' fill='{color}'/>")
|
||||
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("</svg>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _empty(width: int, height: int, title: str) -> str:
|
||||
return (
|
||||
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}'>"
|
||||
f"<rect width='{width}' height='{height}' fill='#ffffff' stroke='#BDBDBD'/>"
|
||||
+ _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED)
|
||||
+ "</svg>"
|
||||
)
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
@@ -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<Error> {
|
||||
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<ReportResponse> {
|
||||
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<void> => {
|
||||
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<void>, overlay = false): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
if (!editable || !dirty) return;
|
||||
await run(async () => {
|
||||
await putBook();
|
||||
showToast(t("E05_Saved"), "success");
|
||||
}, true);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
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<void> {
|
||||
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<Blob> {
|
||||
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<void> {
|
||||
await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`));
|
||||
}
|
||||
|
||||
async function preview(): Promise<void> {
|
||||
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"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("<svg") and "건기 임계면 Fs 1.30" in svg and "노폭 4.00 m" in svg
|
||||
assert "H 4.00 m" in svg and svg.count("<polyline") >= 4
|
||||
@@ -33,6 +33,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;
|
||||
@@ -73,6 +74,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;
|
||||
|
||||
@@ -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<string, LocaleEntry>;
|
||||
Reference in New Issue
Block a user