diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py index 0d89c5822..2c7caa5f3 100644 --- a/E01_StabRegister/E01_StabRegister_Router.py +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field from common_util.common_util_auth import require_company from common_util.common_util_project_kind import create_kind_project from config.config_db import get_db_pool +from E02_StabGround.E02_StabGround_Router import seed_initial router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"]) @@ -84,4 +85,5 @@ async def post_stab_project( project_year=source["project_year"], source_project_id=source["project_id"], ) + await seed_initial(project_id) # E02 초기값(기본값 + B06 측점) return {"project_id": project_id} diff --git a/E02_StabGround/E02_StabGround_Model.py b/E02_StabGround/E02_StabGround_Model.py new file mode 100644 index 000000000..4db6ce336 --- /dev/null +++ b/E02_StabGround/E02_StabGround_Model.py @@ -0,0 +1,230 @@ +"""E02 지반 조건 정본 꼴 · 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3). + +정본 = `E02_StabGround/ground.json` · 초기값 = `initial_snapshot/E02_StabGround/ground.json` +(E 생성 직후 = 마스터 기본값 + B06 측점 암 경계) · 기본값 = `resources/master_template/stability/ +ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Types.Material` 꼴 + `water`. + +⚠ 파이썬·TS 짝 — `check_ground` ↔ `logic/E02_StabGround_Check.ts` `checkGround` · +`resolve_stations` ↔ `resolveStations`. 한쪽을 고치면 다른 쪽도 · 거울 시험 +`resources/tester/test_532_3_e02_ground.py`. +""" + +from __future__ import annotations + +import copy +import json +import math +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DEFAULTS_PATH = ( + PROJECT_ROOT / "resources" / "master_template" / "stability" / "ground_defaults.json" +) +STAGE_DIR = "E02_StabGround" +GROUND_NAME = "ground.json" + +MATERIAL_FIELDS = ("gamma_t", "gamma_sat", "c", "phi") +WATER_GROUPS = ("soil", "rock") +LABELS = ("soil", "ripping_rock", "blasting_rock", "fill") +ROCK_LABELS = ("ripping_rock", "blasting_rock") +CASES = ("dry", "wet", "seismic", "short_term") +SLOPES = ("cut", "fill", "soft") +WATER_KEYS = ("dry", "wet_soil", "wet_rock") +WATER_RULES = ("none", "surface", "half_height") +# 정본에 남기는 칸 — 그 밖(limits · sources · note)은 기본값 파일에만 +GROUND_KEYS = ( + "version", + "materials", + "label_map", + "stations", + "zones", + "safety_factors", + "road_surcharge_kpa", + "groundwater", + "seismic", +) + + +def load_defaults() -> dict[str, Any]: + return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8")) + + +def ground_path(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / GROUND_NAME + + +def initial_path(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE_DIR / GROUND_NAME + + +def pick(doc: dict[str, Any]) -> dict[str, Any]: + """정본 칸만 떠 옴(깊은 복사).""" + return copy.deepcopy({key: doc[key] for key in GROUND_KEYS if key in doc}) + + +def build_initial(defaults: dict[str, Any], stations: list[dict[str, Any]]) -> dict[str, Any]: + """초기값 = 기본값 정본 칸 + B06 측점(암 경계).""" + ground = pick(defaults) + ground["stations"] = stations + return ground + + +def _num(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def _in(value: Any, bound: list[float]) -> bool: + return _num(value) and bound[0] <= value <= bound[1] + + +def _list(value: Any) -> list: + return value if isinstance(value, list) else [] + + +def _dict(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list[dict[str, str]]: + """틀린 칸 목록 [{path, code}] — 빈 목록 = 통과. code = number · range · order · sat_lt_t · + name · name_dup · id_dup · enum · ref · empty · type.""" + errors: list[dict[str, str]] = [] + + def bad(path: str, code: str) -> None: + errors.append({"path": path, "code": code}) + + def ranged(path: str, value: Any, key: str) -> None: + if not _num(value): + bad(path, "number") + elif not _in(value, limits[key]): + bad(path, "range") + + materials = _list(ground.get("materials")) + if not materials: + bad("materials", "empty") + ids: list[str] = [] + names: list[str] = [] + for i, item in enumerate(materials): + base = f"materials.{i}" + if not isinstance(item, dict): + bad(base, "type") + continue + mid = item.get("id") + if not isinstance(mid, str) or not mid or mid in ids: + bad(f"{base}.id", "id_dup") + ids.append(mid if isinstance(mid, str) else "") + name = item["name"].strip() if isinstance(item.get("name"), str) else "" + low, high = limits["name_length"] + if not low <= len(name) <= high: + bad(f"{base}.name", "name") + elif name in names: + bad(f"{base}.name", "name_dup") + names.append(name) + for field in MATERIAL_FIELDS: + ranged(f"{base}.{field}", item.get(field), field) + if _num(item.get("gamma_t")) and _num(item.get("gamma_sat")): + if item["gamma_sat"] < item["gamma_t"]: + bad(f"{base}.gamma_sat", "sat_lt_t") + if item.get("water") not in WATER_GROUPS: + bad(f"{base}.water", "enum") + + label_map = _dict(ground.get("label_map")) + for label in LABELS: + if not label_map.get(label) or label_map.get(label) not in ids: + bad(f"label_map.{label}", "ref") + + for i, zone in enumerate(_list(ground.get("zones"))): + base = f"zones.{i}" + if not isinstance(zone, dict): + bad(base, "type") + continue + ranged(f"{base}.from_m", zone.get("from_m"), "chainage_m") + ranged(f"{base}.to_m", zone.get("to_m"), "chainage_m") + if _num(zone.get("from_m")) and _num(zone.get("to_m")): + if zone["from_m"] >= zone["to_m"]: + bad(f"{base}.to_m", "order") + for layer in ("soil", "rock"): + if zone.get(layer) is not None and zone.get(layer) not in ids: + bad(f"{base}.{layer}", "ref") + if zone.get("soil_thickness_m") is not None: + ranged(f"{base}.soil_thickness_m", zone.get("soil_thickness_m"), "soil_thickness_m") + + factors = _dict(ground.get("safety_factors")) + for case in CASES: + for slope in SLOPES: + value = _dict(factors.get(case)).get(slope) + ranged(f"safety_factors.{case}.{slope}", value, "safety_factor") + + ranged("road_surcharge_kpa", ground.get("road_surcharge_kpa"), "road_surcharge_kpa") + water = _dict(ground.get("groundwater")) + for key in WATER_KEYS: + if water.get(key) not in WATER_RULES: + bad(f"groundwater.{key}", "enum") + seismic = _dict(ground.get("seismic")) + if not isinstance(seismic.get("enabled"), bool): + bad("seismic.enabled", "enum") + ranged("seismic.kh", seismic.get("kh"), "kh") + return errors + + +def clean(ground: dict[str, Any]) -> dict[str, Any]: + """검사를 지난 정본에서 알려진 칸만 남김 · 이름 앞뒤 빈칸 걷음.""" + out = pick(ground) + out["materials"] = [ + { + "id": m["id"], + "name": m["name"].strip(), + **{field: m[field] for field in MATERIAL_FIELDS}, + "water": m["water"], + } + for m in out["materials"] + ] + out["label_map"] = {label: out["label_map"][label] for label in LABELS} + out["zones"] = [ + {key: z.get(key) for key in ("from_m", "to_m", "soil", "rock", "soil_thickness_m")} + for z in _list(out.get("zones")) + ] + factors = out["safety_factors"] + out["safety_factors"] = {c: {s: factors[c][s] for s in SLOPES} for c in CASES} + out["groundwater"] = {key: out["groundwater"][key] for key in WATER_KEYS} + out["seismic"] = {"enabled": out["seismic"]["enabled"], "kh": out["seismic"]["kh"]} + return out + + +def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]: + """측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 + 덮어씀(뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음. + 성토체는 측점과 상관없이 fill 연결.""" + label_map = ground.get("label_map") or {} + out: list[dict[str, Any]] = [] + for station in ground.get("stations") or []: + chainage = station["chainage_m"] + ground_type = station.get("ground_type") + upper = label_map.get("soil") + lower = label_map.get(ground_type) if ground_type in ROCK_LABELS else None + thickness = station.get("soil_thickness_m") + zoned = False + for zone in ground.get("zones") or []: + if not zone["from_m"] <= chainage <= zone["to_m"]: + continue + zoned = True + if zone.get("soil") is not None: + upper = zone["soil"] + if zone.get("rock") is not None: + lower = zone["rock"] + if zone.get("soil_thickness_m") is not None: + thickness = zone["soil_thickness_m"] + if thickness is None: # 경계 없음 = 전부 위 지층 + lower = None + out.append( + { + "chainage_m": chainage, + "ground_type": ground_type, + "upper": upper, + "lower": lower, + "soil_thickness_m": thickness if lower is not None else None, + "zoned": zoned, + } + ) + return out diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py new file mode 100644 index 000000000..aa25b9ace --- /dev/null +++ b/E02_StabGround/E02_StabGround_Router.py @@ -0,0 +1,156 @@ +"""E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3). + +- `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지 +- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` + · 측점(B06 암 경계)은 서버 값 +- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) + +초기값 = E 생성 직후 마스터 기본값 + B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. +공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Repository import get_latest_route +from common_util.common_util_auth import verify_session +from common_util.common_util_json import atomic_write_json +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M +from E02_StabGround import E02_StabGround_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) + + +class GroundRequest(BaseModel): + ground: dict[str, Any] + + +async def _b06_stations(connection: aiomysql.Connection, project_ids: list[str]) -> list[dict]: + """측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음). + + 자기 복사본(532-1)이 먼저 · 없으면 원본 임도.""" + for project_id in project_ids: + route = await get_latest_route(connection, UUID(project_id)) + if not route: + continue + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT chainage_m, data FROM cross_sections WHERE route_id = %s" + " ORDER BY chainage_m", + (route["id"],), + ) + rows = await cursor.fetchall() + stations = [] + for chainage, data in rows: + data = json.loads(data) if isinstance(data, str) else data or {} + design = data.get("design") or {} + ground_type = design.get("ground_type") or "soil" + offset = design.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M) + rock = ground_type in model.ROCK_LABELS and offset is not None + stations.append( + { + "chainage_m": round(float(chainage), 3), + "ground_type": ground_type, + "soil_thickness_m": round(abs(float(offset)), 3) if rock else None, + } + ) + if stations: + return stations + return [] + + +async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]: + async with get_db_pool().acquire() as connection: + try: + stored = await get_project_storage_relative_path(connection, project_id) + except Exception as error: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") from error + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT company_id, project_kind, source_project_id FROM projects WHERE id = %s", + (str(project_id),), + ) + project = await cursor.fetchone() + if not project or project["project_kind"] != "STABILITY": + raise HTTPException(status_code=400, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(stored)), project + + +async def ensure_initial(project_root: Path, project_id: str, source_id: str | None) -> dict: + """초기값이 없을 때만 한 번 — 기본값 + B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET).""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + async with get_db_pool().acquire() as connection: + ids = [project_id] + ([source_id] if source_id else []) + stations = await _b06_stations(connection, ids) + ground = model.build_initial(model.load_defaults(), stations) + atomic_write_json(path, ground) + return ground + + +async def seed_initial(project_id: str) -> None: + """E01 생성 직후 부름 — 초기값 한 벌.""" + root, project = await _project(UUID(project_id)) + await ensure_initial(root, project_id, project["source_project_id"]) + + +def _read_working(project_root: Path) -> dict[str, Any] | None: + path = model.ground_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _editable(session: dict[str, Any], project: dict[str, Any]) -> bool: + return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == project["company_id"] + + +@router.get("/{project_id}/stab/ground") +async def get_ground( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await _project(project_id) + initial = await ensure_initial(root, str(project_id), project["source_project_id"]) + working = _read_working(root) + return { + "ground": working or initial, + "saved": working is not None, + "defaults": model.load_defaults(), + "editable": _editable(session, project), + } + + +@router.put("/{project_id}/stab/ground") +async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + root, project = await _project(project_id) + defaults = model.load_defaults() + errors = model.check_ground(payload.ground, defaults["limits"]) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + ground = model.clean(payload.ground) + # 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음 + base = _read_working(root) or await ensure_initial( + root, str(project_id), project["source_project_id"] + ) + ground["stations"] = base.get("stations", []) + ground["version"] = defaults["version"] + atomic_write_json(model.ground_path(root), ground) + return {"ground": ground} + + +@router.post("/{project_id}/stab/ground/reset") +async def reset_ground(project_id: UUID) -> dict[str, Any]: + root, project = await _project(project_id) + initial = await ensure_initial(root, str(project_id), project["source_project_id"]) + atomic_write_json(model.ground_path(root), initial) + return {"ground": initial} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts new file mode 100644 index 000000000..8bb750ca9 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -0,0 +1,244 @@ +/* ============================================================================= + * E02_StabGround_UI_Page.ts + * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). + * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. + * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · + * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). + * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). + * ========================================================================== */ + +import "./E02_StabGround_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 { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildCards, type E02View } from "./E02_StabGround_UI_Tables"; +import { + checkGround, + type CheckError, + type Ground, + type GroundDefaults, +} from "./logic/E02_StabGround_Check"; + +interface GroundResponse { + ground: Ground; + saved?: boolean; + defaults?: GroundDefaults; + editable?: boolean; +} + +const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; + +/** 검사 열쇠 → 범위 열쇠(limits) */ +function limitKey(path: string): string { + const last = path.split(".").pop() ?? ""; + if (path.startsWith("safety_factors.")) return "safety_factor"; + if (last === "from_m" || last === "to_m") return "chainage_m"; + return last; +} + +function errorText(error: CheckError, defaults: GroundDefaults): string { + const text = t(`E02_Err_${error.code}` as LocaleKey); + const bound = defaults.limits[limitKey(error.path)]; + return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + if (typeof detail === "string") throw new Error(detail); + const errors = (detail as { errors?: CheckError[] } | undefined)?.errors; + throw new Error(errors ? `${t("E02_Invalid")} ${errors.length}` : `HTTP ${response.status}`); + } + return body; +} + +export function renderE02StabGround(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const main = el("div", { className: "e02-page" }); + const status = el("p", { className: "e02-status" }); + const saveButton = createButton({ + label: t("Common_Btn_Save"), + variant: "filled", + onClick: () => void save(), + }); + const resetButton = createButton({ + label: t("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void reset(), + }); + saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(resetButton, saveButton); + const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); + panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions); + mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") })); + + let view: E02View | null = null; + let refreshStations = (): void => {}; + let dirty = false; + + const paintStatus = (errors: CheckError[]): void => { + const lines: string[] = []; + if (view && !view.editable) lines.push(t("E02_ReadOnly")); + if (dirty) lines.push(t("E02_Unsaved")); + if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`); + status.textContent = lines.join("\n"); + status.classList.toggle("is-error", errors.length > 0); + }; + + /** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */ + const mark = (): CheckError[] => { + if (!view) return []; + const errors = checkGround(view.ground, view.defaults.limits); + for (const node of main.querySelectorAll(".is-invalid")) { + node.classList.remove("is-invalid"); + delete node.dataset.error; + } + for (const error of errors) { + const node = main.querySelector(`[data-path="${error.path}"]`); + if (!node) continue; + node.classList.add("is-invalid"); + node.dataset.error = errorText(error, view.defaults); + } + paintStatus(errors); + return errors; + }; + + const render = (): void => { + if (!view) return; + const cards = buildCards(view); + refreshStations = cards.refreshStations; + main.replaceChildren(cards.root); + mark(); + }; + + const remember = (): void => { + if (!view) return; + dirty = true; + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground)); + }; + + const adopt = (body: GroundResponse, keepCache: boolean): void => { + if (!view || !body.defaults) return; + let ground = body.ground; + const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + if (cached) ground = JSON.parse(cached) as Ground; + else sessionStorage.removeItem(cacheKey(projectId)); + dirty = Boolean(cached); + view.ground = ground; + view.defaults = body.defaults; + view.editable = body.editable ?? false; + saveButton.disabled = resetButton.disabled = !view.editable; + render(); + }; + + async function save(): Promise { + if (!view?.editable) return; + const errors = mark(); + if (errors.length) { + showToast( + `${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`, + "error", + ); + return; + } + showLoadingOverlay(); + try { + const body = await request(`/projects/${projectId}/stab/ground`, { + method: "PUT", + body: JSON.stringify({ ground: view.ground }), + }); + adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); + showToast(t("E02_Saved"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function reset(): Promise { + if (!view?.editable) return; + if ( + !(await showConfirmDialog( + t("E02_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + )) + ) + return; + showLoadingOverlay(); + try { + const body = await request(`/projects/${projectId}/stab/ground/reset`, { method: "POST" }); + adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); + showToast(t("E02_ResetDone"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + attachHoverInfo( + main, + (target) => { + if (!view) return null; + const src = target.dataset.src; + const source = src ? view.defaults.sources[src] : undefined; + const userLayer = src?.startsWith("materials.") && !source; + return [ + target.dataset.error, + target.dataset.def !== undefined ? `${t("E02_DefaultIs")} ${target.dataset.def}` : "", + source ?? (userLayer ? t("E02_UserLayer") : ""), + ] + .filter(Boolean) + .join("\n"); + }, + "[data-path]", + ); + + void (async () => { + try { + const body = await request(`/projects/${projectId}/stab/ground`); + view = { + ground: body.ground, + defaults: body.defaults as GroundDefaults, + editable: false, + change: () => { + remember(); + refreshStations(); + mark(); + }, + rebuild: () => { + remember(); + render(); + }, + }; + adopt(body, true); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e02-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + })(); +} diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css new file mode 100644 index 000000000..7a5602ff1 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -0,0 +1,153 @@ +/* ============================================================================= + * E02_StabGround_UI_Style.css + * E02 지반 조건 본문 카드 · 표 · 입력 칸 — 색은 테마 토큰만(밝음 · 어두움 같이) + * ========================================================================== */ + +.e02-page { + height: 100%; + overflow: auto; + padding: var(--spacing-16); + box-sizing: border-box; +} + +.e02-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(560px, 100%), 1fr)); + gap: var(--spacing-16); + align-items: start; +} + +.e02-card { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + padding: var(--spacing-12) var(--spacing-16); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e02-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.e02-card__title, +.e02-card__sub { + margin: 0; + color: var(--color-text); + font-size: var(--text-body); +} + +.e02-card__sub { + font-size: var(--text-body-sm); + margin-top: var(--spacing-8); +} + +.e02-card__actions { + display: flex; + gap: var(--spacing-4); +} + +.e02-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + +.e02-table th { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; + white-space: nowrap; +} + +.e02-table td { + padding: 2px var(--spacing-4); + vertical-align: middle; +} + +.e02-table--read td { + padding: 2px var(--spacing-8); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.e02-table--read tr.is-zoned td { + background: var(--color-mist-violet); + color: var(--color-text); +} + +.e02-stations { + max-height: 320px; + overflow: auto; +} + +.e02-input { + width: 100%; + min-width: 64px; + box-sizing: border-box; +} + +.e02-input--num { + text-align: right; +} + +.e02-input--name { + min-width: 96px; +} + +.e02-input.is-invalid { + border-color: var(--color-danger); + box-shadow: 0 0 0 1px var(--color-danger); +} + +.e02-rowhead { + white-space: nowrap; + color: var(--color-text-secondary); +} + +.e02-map, +.e02-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--spacing-8); + align-items: end; +} + +.e02-map__title { + grid-column: 1 / -1; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.e02-field { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.e02-hint, +.e02-empty { + margin: 0; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.e02-status { + margin: var(--spacing-8) 0 0; + white-space: pre-line; + font-size: var(--text-body-sm); + color: var(--color-warning); +} + +.e02-status.is-error { + color: var(--color-danger); +} diff --git a/E02_StabGround/E02_StabGround_UI_Tables.ts b/E02_StabGround/E02_StabGround_UI_Tables.ts new file mode 100644 index 000000000..1614b16a7 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -0,0 +1,488 @@ +/* ============================================================================= + * E02_StabGround_UI_Tables.ts + * E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) · + * 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지하수 · 지진. 카드마다 [기본값]. + * 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글). + * 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild(). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; +import { + CASES, + LABELS, + MATERIAL_FIELDS, + SLOPES, + WATER_GROUPS, + WATER_KEYS, + WATER_RULES, + resolveStations, + type Ground, + type GroundDefaults, + type Material, +} from "./logic/E02_StabGround_Check"; + +export interface E02View { + ground: Ground; + defaults: GroundDefaults; + editable: boolean; + change: () => void; + rebuild: () => void; +} + +const L = (key: string): string => t(key as LocaleKey); +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = { + gamma_t: "E02_Col_GammaT", + gamma_sat: "E02_Col_GammaSat", + c: "E02_Col_C", + phi: "E02_Col_Phi", +}; + +/** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */ +function numInput( + view: E02View, + path: string, + value: number | null, + set: (v: number | null) => void, + meta: { src?: string; def?: number | null; nullable?: boolean } = {}, +): HTMLInputElement { + const input = el("input", { + className: "ui-input ui-input--sm e02-input e02-input--num", + }) as HTMLInputElement; + input.type = "number"; + input.step = "any"; + input.value = value === null || value === undefined ? "" : String(value); + input.dataset.path = path; + if (meta.src) input.dataset.src = meta.src; + if (meta.def !== undefined && meta.def !== null) input.dataset.def = String(meta.def); + if (meta.nullable) input.placeholder = t("E02_Zone_Keep"); + input.disabled = !view.editable; + input.addEventListener("input", () => { + const raw = input.value.trim(); + set(raw === "" ? (meta.nullable ? null : Number.NaN) : Number(raw)); + view.change(); + }); + return input; +} + +function selectInput( + view: E02View, + path: string, + options: { value: string; text: string }[], + value: string | null, + set: (v: string) => void, + meta: { src?: string; def?: string } = {}, +): HTMLSelectElement { + const select = el("select", { + className: "ui-select ui-select--sm e02-input", + }) as HTMLSelectElement; + for (const option of options) select.append(new Option(option.text, option.value)); + select.value = value ?? ""; + select.dataset.path = path; + if (meta.src) select.dataset.src = meta.src; + if (meta.def) select.dataset.def = meta.def; + select.disabled = !view.editable; + select.addEventListener("change", () => { + set(select.value); + view.change(); + }); + return select; +} + +function card( + title: LocaleKey, + ...body: HTMLElement[] +): { section: HTMLElement; head: HTMLElement } { + const head = el("div", { className: "e02-card__head" }); + head.append(el("h3", { className: "e02-card__title", text: t(title) })); + const section = el("section", { className: "e02-card ui-sidebar-section" }); + section.append(head, ...body); + return { section, head }; +} + +function headButtons(view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[]): void { + if (!view.editable) return; + const row = el("div", { className: "e02-card__actions" }); + row.append(...buttons); + head.append(row); +} + +function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement { + const node = el("table", { className: "e02-table" }) as HTMLTableElement; + const tr = node.createTHead().insertRow(); + for (const text of headers) tr.append(el("th", { text })); + const body = node.createTBody(); + for (const cells of rows) { + const row = body.insertRow(); + for (const cell of cells) row.insertCell().append(cell); + } + return node; +} + +function defaultMaterial(view: E02View, id: string): Material | undefined { + return view.defaults.materials.find((m) => m.id === id); +} + +function layerOptions(ground: Ground, blank?: string): { value: string; text: string }[] { + const list = ground.materials.map((m) => ({ value: m.id, text: m.name || m.id })); + return blank === undefined ? list : [{ value: "", text: blank }, ...list]; +} + +/* --- 1. 지반물성치 --- */ +function materialsCard(view: E02View): HTMLElement { + const g = view.ground; + const used = (id: string): boolean => + Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id); + const rows = g.materials.map((m, i) => { + const base = `materials.${i}`; + const name = el("input", { + className: "ui-input ui-input--sm e02-input e02-input--name", + }) as HTMLInputElement; + name.value = m.name; + name.dataset.path = `${base}.name`; + name.disabled = !view.editable; + name.addEventListener("input", () => { + m.name = name.value; + view.change(); + }); + const cells: HTMLElement[] = [name]; + for (const field of MATERIAL_FIELDS) + cells.push( + numInput(view, `${base}.${field}`, m[field], (v) => (m[field] = v as number), { + src: `materials.${m.id}.${field}`, + def: defaultMaterial(view, m.id)?.[field] ?? null, + }), + ); + cells.push( + selectInput( + view, + `${base}.water`, + WATER_GROUPS.map((w) => ({ + value: w, + text: L(w === "soil" ? "E02_Water_Soil" : "E02_Water_Rock"), + })), + m.water, + (v) => (m.water = v as Material["water"]), + ), + ); + const remove = createButton({ + label: t("E02_Btn_Remove"), + variant: "danger-ghost", + size: "sm", + onClick: () => { + if (used(m.id)) return void showToast(t("E02_InUse"), "warning"); + g.materials.splice(i, 1); + view.rebuild(); + }, + }); + remove.hidden = !view.editable; + cells.push(remove); + return cells; + }); + const headers = [ + t("E02_Col_Name"), + ...MATERIAL_FIELDS.map((f) => t(FIELD_LABEL[f])), + t("E02_Col_Water"), + "", + ]; + + const mapRow = el("div", { className: "e02-map" }); + mapRow.append(el("span", { className: "e02-map__title", text: t("E02_LabelMap") })); + for (const label of LABELS) { + const field = el("label", { className: "e02-field" }); + field.append( + el("span", { text: L(`E02_Label_${label}`) }), + selectInput(view, `label_map.${label}`, layerOptions(g), g.label_map[label], (v) => { + g.label_map[label] = v; + view.rebuild(); + }), + ); + mapRow.append(field); + } + + const { section, head } = card("E02_Card_Materials", table(headers, rows), mapRow); + headButtons( + view, + head, + createButton({ + label: t("E02_Btn_AddLayer"), + variant: "ghost", + size: "sm", + onClick: () => { + let n = 1; + while (g.materials.some((m) => m.name === `${t("E02_NewLayer")} ${n}`)) n += 1; + let id = `user_${n}`; + for (let k = n; g.materials.some((m) => m.id === id); k += 1) id = `user_${k}`; + const seed = view.defaults.materials[0]; + g.materials.push({ ...seed, id, name: `${t("E02_NewLayer")} ${n}` }); + view.rebuild(); + }, + }), + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + g.materials = clone(view.defaults.materials); + g.label_map = clone(view.defaults.label_map); + const ids = new Set(g.materials.map((m) => m.id)); + for (const zone of g.zones) { + if (zone.soil !== null && !ids.has(zone.soil)) zone.soil = null; + if (zone.rock !== null && !ids.has(zone.rock)) zone.rock = null; + } + view.rebuild(); + }, + }), + ); + return section; +} + +/* --- 2. 층 두께 · 구간 덮어쓰기 + 측점별 적용 --- */ +function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } { + const g = view.ground; + const rows = g.zones.map((z, i) => { + const base = `zones.${i}`; + const remove = createButton({ + label: t("E02_Btn_Remove"), + variant: "danger-ghost", + size: "sm", + onClick: () => { + g.zones.splice(i, 1); + view.rebuild(); + }, + }); + remove.hidden = !view.editable; + const layer = (key: "soil" | "rock"): HTMLSelectElement => + selectInput(view, `${base}.${key}`, layerOptions(g, t("E02_Zone_Keep")), z[key], (v) => { + z[key] = v || null; + }); + return [ + numInput(view, `${base}.from_m`, z.from_m, (v) => (z.from_m = v as number)), + numInput(view, `${base}.to_m`, z.to_m, (v) => (z.to_m = v as number)), + layer("soil"), + layer("rock"), + numInput( + view, + `${base}.soil_thickness_m`, + z.soil_thickness_m, + (v) => (z.soil_thickness_m = v), + { + nullable: true, + src: "stations", + }, + ), + remove, + ]; + }); + const zoneBody = rows.length + ? table( + [ + t("E02_Col_From"), + t("E02_Col_To"), + t("E02_Col_Upper"), + t("E02_Col_Lower"), + t("E02_Col_Thickness"), + "", + ], + rows, + ) + : el("p", { className: "e02-empty", text: t("E02_Zone_Empty") }); + + const stationsHost = el("div", { className: "e02-stations" }); + const refresh = (): void => { + stationsHost.replaceChildren(); + if (!g.stations.length) + return void stationsHost.append( + el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }), + ); + const names = new Map(g.materials.map((m) => [m.id, m.name])); + const interval = inferStationInterval(g.stations); + const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2)); + const resolved = resolveStations(g); + const node = table( + [ + t("E02_Col_Station"), + t("E02_Col_GroundType"), + t("E02_Col_B06Thickness"), + t("E02_Col_Upper"), + t("E02_Col_Lower"), + t("E02_Col_Thickness"), + ], + resolved.map((r, i) => [ + el("span", { text: stationLabel(r.chainage_m, interval) }), + el("span", { text: L(`E02_Ground_${r.ground_type}`) }), + el("span", { text: fmt(g.stations[i].soil_thickness_m) }), + el("span", { text: (r.upper && names.get(r.upper)) || "-" }), + el("span", { text: r.lower ? (names.get(r.lower) ?? "-") : t("E02_AllUpper") }), + el("span", { text: fmt(r.soil_thickness_m) }), + ]), + ); + resolved.forEach((r, i) => node.tBodies[0].rows[i].classList.toggle("is-zoned", r.zoned)); + node.classList.add("e02-table--read"); + stationsHost.append(node); + }; + refresh(); + + const { section, head } = card( + "E02_Card_Zones", + el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }), + zoneBody, + el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }), + stationsHost, + ); + const first = g.stations[0]?.chainage_m ?? 0; + const last = g.stations[g.stations.length - 1]?.chainage_m ?? 100; + headButtons( + view, + head, + createButton({ + label: t("E02_Btn_AddZone"), + variant: "ghost", + size: "sm", + onClick: () => { + g.zones.push({ + from_m: first, + to_m: last > first ? last : first + 20, + soil: null, + rock: null, + soil_thickness_m: null, + }); + view.rebuild(); + }, + }), + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + g.zones = clone(view.defaults.zones); + view.rebuild(); + }, + }), + ); + return { section, refresh }; +} + +/* --- 3. 기준안전율 --- */ +function safetyCard(view: E02View): HTMLElement { + const g = view.ground; + const rows = CASES.map((c) => [ + el("span", { className: "e02-rowhead", text: L(`E02_Case_${c}`) }), + ...SLOPES.map((s) => + numInput( + view, + `safety_factors.${c}.${s}`, + g.safety_factors[c][s], + (v) => (g.safety_factors[c][s] = v as number), + { + src: `safety_factors.${c}`, + def: view.defaults.safety_factors[c][s], + }, + ), + ), + ]); + const { section, head } = card( + "E02_Card_Safety", + table(["", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows), + ); + headButtons( + view, + head, + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + g.safety_factors = clone(view.defaults.safety_factors); + view.rebuild(); + }, + }), + ); + return section; +} + +/* --- 4. 노면 하중 · 지하수 · 지진 --- */ +function loadsCard(view: E02View): HTMLElement { + const g = view.ground; + const d = view.defaults; + const field = (label: string, input: HTMLElement): HTMLElement => { + const node = el("label", { className: "e02-field" }); + node.append(el("span", { text: label }), input); + return node; + }; + const kh = numInput(view, "seismic.kh", g.seismic.kh, (v) => (g.seismic.kh = v as number), { + src: "seismic.kh", + def: d.seismic.kh, + }); + kh.disabled = !view.editable || !g.seismic.enabled; + const seismic = createCheckbox(t("E02_Seismic"), { checked: g.seismic.enabled }); + seismic.input.dataset.path = "seismic.enabled"; + seismic.input.dataset.src = "seismic.enabled"; + seismic.input.dataset.def = d.seismic.enabled ? "on" : "off"; + seismic.input.disabled = !view.editable; + seismic.input.addEventListener("change", () => { + g.seismic.enabled = seismic.input.checked; + kh.disabled = !view.editable || !g.seismic.enabled; + view.change(); + }); + const water = WATER_KEYS.map((key) => + field( + L(`E02_Water_${key}`), + selectInput( + view, + `groundwater.${key}`, + WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })), + g.groundwater[key], + (v) => (g.groundwater[key] = v as (typeof WATER_RULES)[number]), + { src: `groundwater.${key}`, def: L(`E02_Rule_${d.groundwater[key]}`) }, + ), + ), + ); + const grid = el("div", { className: "e02-grid" }); + grid.append( + field( + t("E02_Surcharge"), + numInput( + view, + "road_surcharge_kpa", + g.road_surcharge_kpa, + (v) => (g.road_surcharge_kpa = v as number), + { + src: "road_surcharge_kpa", + def: d.road_surcharge_kpa, + }, + ), + ), + ...water, + seismic.root, + field(t("E02_Kh"), kh), + ); + const { section, head } = card("E02_Card_Loads", grid); + headButtons( + view, + head, + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + g.road_surcharge_kpa = d.road_surcharge_kpa; + g.groundwater = clone(d.groundwater); + g.seismic = clone(d.seismic); + view.rebuild(); + }, + }), + ); + return section; +} + +/** 본문 전체 — 돌려준 refreshStations = 값만 바뀐 뒤 측점별 적용 표를 다시 그림 */ +export function buildCards(view: E02View): { root: HTMLElement; refreshStations: () => void } { + const root = el("div", { className: "e02-cards" }); + const zones = zonesCard(view); + root.append(materialsCard(view), zones.section, safetyCard(view), loadsCard(view)); + return { root, refreshStations: zones.refresh }; +} diff --git a/E02_StabGround/draft/E02_StabGround_Route.ts b/E02_StabGround/draft/E02_StabGround_Route.ts index 13e8f23bb..1bf53ce98 100644 --- a/E02_StabGround/draft/E02_StabGround_Route.ts +++ b/E02_StabGround/draft/E02_StabGround_Route.ts @@ -1,7 +1,7 @@ /* ============================================================================= * E02_StabGround_Route.ts - * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. - * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E02 지반 조건 페이지 * ========================================================================== */ 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.E02_STAB_GROUND), + load: async () => (await import("../E02_StabGround_UI_Page")).renderE02StabGround, }; diff --git a/E02_StabGround/logic/E02_StabGround_Check.ts b/E02_StabGround/logic/E02_StabGround_Check.ts new file mode 100644 index 000000000..99a4baf75 --- /dev/null +++ b/E02_StabGround/logic/E02_StabGround_Check.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * E02_StabGround_Check.ts + * E02 지반 조건 꼴 · 입력 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3) — 순수 함수만. + * + * ⚠ 파이썬·TS 짝 — `checkGround` ↔ `E02_StabGround_Model.py` `check_ground` · + * `resolveStations` ↔ `resolve_stations`. 한쪽을 고치면 다른 쪽도 · 거울 시험 + * `resources/tester/test_532_3_e02_ground.py`. + * ========================================================================== */ + +export type WaterGroup = "soil" | "rock"; +export type WaterRule = "none" | "surface" | "half_height"; + +/** 물성 한 줄 = E03_StabSection_Types.Material + 지하수 무리 */ +export interface Material { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; + water: WaterGroup; +} + +export interface Station { + chainage_m: number; + ground_type: string; + soil_thickness_m: number | null; +} + +export interface Zone { + from_m: number; + to_m: number; + soil: string | null; + rock: string | null; + soil_thickness_m: number | null; +} + +export type SlopeFactors = Record<(typeof SLOPES)[number], number>; + +export interface Ground { + version: number; + materials: Material[]; + label_map: Record<(typeof LABELS)[number], string>; + stations: Station[]; + zones: Zone[]; + safety_factors: Record<(typeof CASES)[number], SlopeFactors>; + road_surcharge_kpa: number; + groundwater: Record<(typeof WATER_KEYS)[number], WaterRule>; + seismic: { enabled: boolean; kh: number }; +} + +export type Limits = Record; + +export interface GroundDefaults extends Ground { + note: string; + limits: Limits; + sources: Record; +} + +export interface CheckError { + path: string; + code: string; +} + +export interface ResolvedStation { + chainage_m: number; + ground_type: string; + upper: string | null; + lower: string | null; + soil_thickness_m: number | null; + zoned: boolean; +} + +export const MATERIAL_FIELDS = ["gamma_t", "gamma_sat", "c", "phi"] as const; +export const WATER_GROUPS: readonly WaterGroup[] = ["soil", "rock"]; +export const LABELS = ["soil", "ripping_rock", "blasting_rock", "fill"] as const; +const ROCK_LABELS = ["ripping_rock", "blasting_rock"]; +export const CASES = ["dry", "wet", "seismic", "short_term"] as const; +export const SLOPES = ["cut", "fill", "soft"] as const; +export const WATER_KEYS = ["dry", "wet_soil", "wet_rock"] as const; +export const WATER_RULES: readonly WaterRule[] = ["none", "surface", "half_height"]; + +type Loose = Record; +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const asList = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const isDict = (v: unknown): v is Loose => typeof v === "object" && v !== null && !Array.isArray(v); +const asDict = (v: unknown): Loose => (isDict(v) ? v : {}); + +/** 틀린 칸 목록 — 빈 목록 = 통과(code 는 파이썬과 같은 낱말) */ +export function checkGround(ground: unknown, limits: Limits): CheckError[] { + const errors: CheckError[] = []; + const bad = (path: string, code: string): void => void errors.push({ path, code }); + const ranged = (path: string, value: unknown, key: string): void => { + const [low, high] = limits[key]; + if (!isNum(value)) bad(path, "number"); + else if (!(low <= value && value <= high)) bad(path, "range"); + }; + const doc = asDict(ground); + + const materials = asList(doc.materials); + if (!materials.length) bad("materials", "empty"); + const ids: string[] = []; + const names: string[] = []; + materials.forEach((item, i) => { + const base = `materials.${i}`; + if (!isDict(item)) return bad(base, "type"); + const mid = item.id; + if (typeof mid !== "string" || !mid || ids.includes(mid)) bad(`${base}.id`, "id_dup"); + ids.push(typeof mid === "string" ? mid : ""); + const name = typeof item.name === "string" ? item.name.trim() : ""; + const [low, high] = limits.name_length; + if (!(low <= name.length && name.length <= high)) bad(`${base}.name`, "name"); + else if (names.includes(name)) bad(`${base}.name`, "name_dup"); + names.push(name); + for (const field of MATERIAL_FIELDS) ranged(`${base}.${field}`, item[field], field); + if (isNum(item.gamma_t) && isNum(item.gamma_sat) && item.gamma_sat < item.gamma_t) + bad(`${base}.gamma_sat`, "sat_lt_t"); + if (!WATER_GROUPS.includes(item.water as WaterGroup)) bad(`${base}.water`, "enum"); + }); + + const labelMap = asDict(doc.label_map); + for (const label of LABELS) { + const ref = labelMap[label]; + if (!ref || !ids.includes(ref as string)) bad(`label_map.${label}`, "ref"); + } + + asList(doc.zones).forEach((zone, i) => { + const base = `zones.${i}`; + if (!isDict(zone)) return bad(base, "type"); + ranged(`${base}.from_m`, zone.from_m, "chainage_m"); + ranged(`${base}.to_m`, zone.to_m, "chainage_m"); + if (isNum(zone.from_m) && isNum(zone.to_m) && zone.from_m >= zone.to_m) + bad(`${base}.to_m`, "order"); + for (const layer of ["soil", "rock"]) { + const ref = zone[layer]; + if (ref !== null && ref !== undefined && !ids.includes(ref as string)) + bad(`${base}.${layer}`, "ref"); + } + if (zone.soil_thickness_m !== null && zone.soil_thickness_m !== undefined) + ranged(`${base}.soil_thickness_m`, zone.soil_thickness_m, "soil_thickness_m"); + }); + + const factors = asDict(doc.safety_factors); + for (const c of CASES) + for (const s of SLOPES) + ranged(`safety_factors.${c}.${s}`, asDict(factors[c])[s], "safety_factor"); + + ranged("road_surcharge_kpa", doc.road_surcharge_kpa, "road_surcharge_kpa"); + const water = asDict(doc.groundwater); + for (const key of WATER_KEYS) + if (!WATER_RULES.includes(water[key] as WaterRule)) bad(`groundwater.${key}`, "enum"); + const seismic = asDict(doc.seismic); + if (typeof seismic.enabled !== "boolean") bad("seismic.enabled", "enum"); + ranged("seismic.kh", seismic.kh, "kh"); + return errors; +} + +/** 측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 덮어씀 + * (뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음 */ +export function resolveStations(ground: Ground): ResolvedStation[] { + return ground.stations.map((station) => { + const chainage = station.chainage_m; + let upper: string | null = ground.label_map.soil ?? null; + let lower: string | null = ROCK_LABELS.includes(station.ground_type) + ? (ground.label_map[station.ground_type as (typeof LABELS)[number]] ?? null) + : null; + let thickness = station.soil_thickness_m; + let zoned = false; + for (const zone of ground.zones) { + if (!(zone.from_m <= chainage && chainage <= zone.to_m)) continue; + zoned = true; + if (zone.soil != null) upper = zone.soil; + if (zone.rock != null) lower = zone.rock; + if (zone.soil_thickness_m != null) thickness = zone.soil_thickness_m; + } + if (thickness === null) lower = null; // 경계 없음 = 전부 위 지층 + return { + chainage_m: chainage, + ground_type: station.ground_type, + upper, + lower, + soil_thickness_m: lower !== null ? thickness : null, + zoned, + }; + }); +} diff --git a/main.py b/main.py index b7b8c5dce..79205adca 100644 --- a/main.py +++ b/main.py @@ -76,6 +76,7 @@ from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_das from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router 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 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 @@ -478,6 +479,7 @@ app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(d03_plan_analysis_router, dependencies=protected_with_company) +app.include_router(e02_stab_ground_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/master_template/stability/ground_defaults.json b/resources/master_template/stability/ground_defaults.json new file mode 100644 index 000000000..65ceb41f6 --- /dev/null +++ b/resources/master_template/stability/ground_defaults.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "note": "문헌 추정치 · 현장 시험 없음 · 값은 사용자가 바꿀 수 있음(E02 지반 조건)", + "materials": [ + { "id": "weathered_soil", "name": "풍화토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 15.0, "phi": 30.0, "water": "soil" }, + { "id": "weathered_rock", "name": "풍화암", "gamma_t": 20.0, "gamma_sat": 21.0, "c": 30.0, "phi": 33.0, "water": "soil" }, + { "id": "soft_rock", "name": "연암", "gamma_t": 24.0, "gamma_sat": 24.5, "c": 150.0, "phi": 35.0, "water": "rock" }, + { "id": "hard_rock", "name": "경암", "gamma_t": 26.0, "gamma_sat": 26.0, "c": 300.0, "phi": 40.0, "water": "rock" }, + { "id": "fill", "name": "성토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 10.0, "phi": 28.0, "water": "soil" }, + { "id": "colluvium", "name": "붕적토", "gamma_t": 17.5, "gamma_sat": 18.5, "c": 5.0, "phi": 27.0, "water": "soil" } + ], + "label_map": { "soil": "weathered_soil", "ripping_rock": "weathered_rock", "blasting_rock": "soft_rock", "fill": "fill" }, + "stations": [], + "zones": [], + "safety_factors": { + "dry": { "cut": 1.5, "fill": 1.5, "soft": 1.3 }, + "wet": { "cut": 1.2, "fill": 1.3, "soft": 1.2 }, + "seismic": { "cut": 1.1, "fill": 1.1, "soft": 1.1 }, + "short_term": { "cut": 1.1, "fill": 1.1, "soft": 1.1 } + }, + "road_surcharge_kpa": 10.0, + "groundwater": { "dry": "none", "wet_soil": "surface", "wet_rock": "half_height" }, + "seismic": { "enabled": false, "kh": 0.0 }, + "limits": { + "gamma_t": [10.0, 30.0], + "gamma_sat": [10.0, 30.0], + "c": [0.0, 1000.0], + "phi": [0.0, 60.0], + "safety_factor": [1.0, 3.0], + "road_surcharge_kpa": [0.0, 100.0], + "kh": [0.0, 0.5], + "soil_thickness_m": [0.0, 50.0], + "chainage_m": [0.0, 100000.0], + "name_length": [1, 30] + }, + "sources": { + "materials.weathered_soil.gamma_t": "07 §3.3(1) 서울시 「지반조사편람」(2006) 풍화토 17~20 · §3.2(2) 「도로교시방서」 표 〈N값과 단위중량〉 사질토 N 10~30 = 18 · §4 종합 18~19", + "materials.weathered_soil.gamma_sat": "07 §3.2(2) 「도로교시방서」 γsat ≈ γ′ + 10 · N 10~30 = 18~19 · §4 종합 19~20", + "materials.weathered_soil.c": "07 §3.3(1) 「지반조사편람」 풍화토 비탈면 실무 5~20 kPa · §4 종합 5~20", + "materials.weathered_soil.phi": "07 §3.3(1) 「지반조사편람」 풍화토 25~30°(조밀 32°) · §4 종합 27~32", + "materials.weathered_rock.gamma_t": "07 §3.3(2) 한국지반공학회 사면안정 학술발표회(1996~2003) 풍화암 19.5~21.0 · §4 종합", + "materials.weathered_rock.gamma_sat": "07 §4 종합 풍화암 20.5~22.0", + "materials.weathered_rock.c": "07 §3.3(2) 사면안정 학술발표회 비탈면 해석용 풍화암 10~30 kPa(1996 · 2001 = 30)", + "materials.weathered_rock.phi": "07 §3.3(2) 사면안정 학술발표회 풍화암 30~36° · §4 종합 30~36", + "materials.soft_rock.gamma_t": "07 §4 종합 연암 22~24(범위 20~25) · §3.4(3) Hoek & Bray 연질 퇴적암 실무 22~24", + "materials.soft_rock.gamma_sat": "07 §4 종합 연암 23~25", + "materials.soft_rock.c": "07 §3.4(1) 한국도로공사 「도로설계실무편람」(1996) 연암 · 보통암(중간 파쇄) 150 kPa", + "materials.soft_rock.phi": "07 §3.4(1) 「도로설계실무편람」 연암 · 보통암(중간 파쇄) 35°", + "materials.hard_rock.gamma_t": "07 §3.4(3) Hoek & Bray 화성암 · 변성암 실무 25~27 · §4 종합 25~27", + "materials.hard_rock.gamma_sat": "07 §4 종합 경암 25.5~27.5", + "materials.hard_rock.c": "07 §3.4(2) 「건설공사 비탈면 설계기준 해설」 Bieniawski RMR Ⅱ등급 300~400 kPa · §3.4(1) 도로설계실무편람 양호 경암 200 이상", + "materials.hard_rock.phi": "07 §3.4(1) 「도로설계실무편람」 양호한 연암 · 경암 40° · §4 종합 40~45", + "materials.fill.gamma_t": "07 §4 종합 성토 18~19 · §3.1 「도로설계요령」 표 〈토사의 개략적인 토질 정수〉 밀실 사질토 19", + "materials.fill.gamma_sat": "07 §4 종합 성토 19~20", + "materials.fill.c": "07 §4 종합 성토 0~15 kPa(비탈면 실무 5~15 보수 적용)", + "materials.fill.phi": "07 §4 종합 성토 28~30 · §3.1 「도로설계요령」 밀실 사질토 30° — 아래값 28", + "materials.colluvium.gamma_t": "07 §3.1 「도로설계요령」 밀실치 않은 사질토(붕적토) 17 · §3.2(2) 「도로교시방서」 N < 10 = 17 · §4 종합 17~18", + "materials.colluvium.gamma_sat": "07 §3.2(2) 「도로교시방서」 N < 10 γsat 17~18 · §4 종합 18~19", + "materials.colluvium.c": "07 §3.3(1) 「지반조사편람」 퇴적토 · 붕적토 0~15 · §4 종합 0~10", + "materials.colluvium.phi": "07 §3.1 「도로설계요령」 붕적토 25° · §3.3(1) 25~30 · §4 종합 25~28", + "safety_factors.dry": "KDS 11 70 05 표 4.3-1(쌓기 일반) · 4.3-2(연약지반 쌓기) · 4.3-3(깎기) 건기", + "safety_factors.wet": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 우기 — 깎기 1.2 = 지하수위 결정 방식(강우침투 해석이면 1.3)", + "safety_factors.seismic": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 지진 시", + "safety_factors.short_term": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 단기(1년 미만 · 시공 중)", + "road_surcharge_kpa": "옹벽 배면 상재하중 10 kN/㎡ 준용(지식DB technical_info 구조물_안정계산 · KDS 11 80 05) · 임도 차량하중 고유 근거 없음", + "groundwater.dry": "KDS 11 70 05 표 4.3-3 건기 = 지하수 없음", + "groundwater.wet_soil": "KDS 11 70 05 표 4.3-3 우기 토층 · 풍화암 = 지하수위 결정(지표 포화 가정)", + "groundwater.wet_rock": "KDS 11 70 05 표 4.3-3 우기 연암 · 경암 = 비탈면 높이 ½ 심도까지 포화", + "seismic.enabled": "KDS 11 90 00 1.8.1 ② 영향 범위에 주구조물 없으면 비적용 · 임도는 발주자 협의 — 기본 끔", + "seismic.kh": "유사정적 수평지진계수 — 지반가속도의 비율로 사용자 입력(KDS 11 70 05 표 4.3 지진 시)", + "stations": "B06 횡단 암 경계(지반선 평행 · 기본 −0.5 m · 물량 가름용 값) — 토사 측점은 경계 없음" + } +} diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index b88997378..9f226bcf2 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -138,7 +138,12 @@ def _e01(monkeypatch, rows): return "stab-id" monkeypatch.setattr(e01, "_sources", fake_sources) + + async def fake_seed(project_id): + seen["seeded"] = project_id + monkeypatch.setattr(e01, "create_kind_project", fake_create) + monkeypatch.setattr(e01, "seed_initial", fake_seed) payload = e01.CreateStabRequest(source_project_id="s" * 36) return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER)) @@ -157,6 +162,7 @@ def test_e01_requires_b06(monkeypatch): assert call() == {"project_id": "stab-id"} assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36 assert seen["name"] == "임도 안정성평가" + assert seen["seeded"] == "stab-id" # E02 초기값(532-3) def _access(monkeypatch, method, assigned, owner_company=2): diff --git a/resources/tester/test_532_3_e02_ground.py b/resources/tester/test_532_3_e02_ground.py new file mode 100644 index 000000000..bb04b9a17 --- /dev/null +++ b/resources/tester/test_532_3_e02_ground.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +"""532-3 E02 지반 조건 — 기본값 json 꼴 = E03 Types · KDS 표 · 검사 · 측점 적용 거울(파이썬 ↔ TS) · +[저장] · [초기화] · 지층 추가 → 저장 → 다시 읽음(임시 storage · DB 대신 측점 가짜). +""" + +import asyncio +import copy +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from E02_StabGround import E02_StabGround_Model as model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as router # noqa: E402 +from E03_StabSection import E03_StabSection_Types as types # noqa: E402 + +TS = PROJECT_ROOT / "E02_StabGround" / "logic" / "E02_StabGround_Check.ts" +TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript" +DEFAULTS = model.load_defaults() +LIMITS = DEFAULTS["limits"] +STATIONS = [ + {"chainage_m": 0.0, "ground_type": "soil", "soil_thickness_m": None}, + {"chainage_m": 20.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5}, + {"chainage_m": 40.0, "ground_type": "blasting_rock", "soil_thickness_m": 0.8}, + {"chainage_m": 60.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5}, +] + + +def _ground(**changes): + ground = model.build_initial(DEFAULTS, copy.deepcopy(STATIONS)) + ground.update(changes) + return ground + + +def _bad(mutate): + ground = _ground() + mutate(ground) + return ground + + +# 거울 시험 판 — 통과 하나 + 틀린 꼴 여럿(NaN 은 JSON 으로 못 넘겨 null · 글 · 참거짓으로) +CHECK_CASES = [ + _ground(), + _bad(lambda g: g["materials"][0].update(gamma_t=9.9)), + _bad(lambda g: g["materials"][0].update(gamma_sat=17.0)), + _bad(lambda g: g["materials"][1].update(c=None, phi="33")), + _bad(lambda g: g["materials"][2].update(phi=True, water="sand")), + _bad(lambda g: g["materials"][3].update(name=" ", id="soft_rock")), + _bad(lambda g: g["materials"][4].update(name="풍화토")), + _bad(lambda g: g["materials"][5].update(name="가" * 31)), + _bad(lambda g: g["materials"].append(7)), + _bad(lambda g: g.update(materials=[])), + _bad(lambda g: g["label_map"].update(soil="nope", fill="")), + _bad(lambda g: g.update(zones=[{"from_m": 50, "to_m": 50, "soil": "x", "rock": None}])), + _bad( + lambda g: g.update( + zones=[ + { + "from_m": -1, + "to_m": 10, + "soil": None, + "rock": "hard_rock", + "soil_thickness_m": 51, + }, + "zone", + ] + ) + ), + _bad(lambda g: g["safety_factors"]["wet"].update(cut=0.9, soft=3.1)), + _bad(lambda g: g["safety_factors"].pop("seismic")), + _bad(lambda g: g.update(road_surcharge_kpa=101)), + _bad(lambda g: g["groundwater"].update(wet_rock="full")), + _bad(lambda g: g.update(seismic={"enabled": "yes", "kh": 0.6})), + _bad(lambda g: g.update(label_map=[], safety_factors=None, groundwater="x")), +] + +RESOLVE_CASES = [ + _ground(), + _ground( + zones=[ + {"from_m": 0, "to_m": 20, "soil": "colluvium", "rock": None, "soil_thickness_m": None}, + {"from_m": 20, "to_m": 50, "soil": None, "rock": "hard_rock", "soil_thickness_m": 2.5}, + {"from_m": 55, "to_m": 70, "soil": None, "rock": None, "soil_thickness_m": None}, + ] + ), + _ground( + zones=[{"from_m": 0, "to_m": 10, "soil": None, "rock": "soft_rock", "soil_thickness_m": 1}] + ), +] + +SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const src = fs.readFileSync(process.argv[2], "utf8"); +const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, opts).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const m = box.exports; +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify({ + check: input.check.map((g) => m.checkGround(g, input.limits)), + resolve: input.resolve.map((g) => m.resolveStations(g)), +})); +""" + + +def test_기본값_꼴은_Types_그대로(): + assert model.check_ground(DEFAULTS, LIMITS) == [] + assert [m["id"] for m in DEFAULTS["materials"]] == [m["id"] for m in types.DEFAULT_MATERIALS] + for item in DEFAULTS["materials"]: + assert types.material(item) == {k: item[k] for k in types.Material.__annotations__} + for field in model.MATERIAL_FIELDS: + assert DEFAULTS["sources"][f"materials.{item['id']}.{field}"].startswith("07 ") + assert DEFAULTS["road_surcharge_kpa"] == types.DEFAULT_ROAD_SURCHARGE_KPA == 10.0 + assert DEFAULTS["groundwater"] == { + "dry": "none", + "wet_soil": "surface", + "wet_rock": "half_height", + } + assert DEFAULTS["seismic"] == {"enabled": False, "kh": 0.0} + # 07 §4 종합 성토 φ 28~30 — 브레인 기본 25 는 범위 밖이라 아래값 28 + assert next(m for m in DEFAULTS["materials"] if m["id"] == "fill")["phi"] == 28.0 + assert types.groundwater({"mode": "none"}) == {"mode": "none"} + + +def test_기준안전율_KDS_11_70_05(): + assert DEFAULTS["safety_factors"] == { + "dry": {"cut": 1.5, "fill": 1.5, "soft": 1.3}, + "wet": {"cut": 1.2, "fill": 1.3, "soft": 1.2}, + "seismic": {"cut": 1.1, "fill": 1.1, "soft": 1.1}, + "short_term": {"cut": 1.1, "fill": 1.1, "soft": 1.1}, + } + + +def test_범위_검사(): + codes = lambda g: {(e["path"], e["code"]) for e in model.check_ground(g, LIMITS)} # noqa: E731 + assert codes(CHECK_CASES[1]) == {("materials.0.gamma_t", "range")} + assert codes(CHECK_CASES[2]) == {("materials.0.gamma_sat", "sat_lt_t")} + assert ("materials.4.name", "name_dup") in codes(CHECK_CASES[6]) + assert ("zones.0.to_m", "order") in codes(CHECK_CASES[11]) + assert ("zones.0.soil", "ref") in codes(CHECK_CASES[11]) + assert codes(CHECK_CASES[13]) == { + ("safety_factors.wet.cut", "range"), + ("safety_factors.wet.soft", "range"), + } + assert codes(CHECK_CASES[17]) == {("seismic.enabled", "enum"), ("seismic.kh", "range")} + + +def test_파이썬과_TS_같음(): + payload = json.dumps({"limits": LIMITS, "check": CHECK_CASES, "resolve": RESOLVE_CASES}) + proc = subprocess.run( + ["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)], + input=payload, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["check"] == [model.check_ground(g, LIMITS) for g in CHECK_CASES] + assert out["resolve"] == [model.resolve_stations(g) for g in RESOLVE_CASES] + assert all(out["check"][1:]) # 틀린 판은 모두 걸림 + + +def test_측점_적용(): + rows = model.resolve_stations(RESOLVE_CASES[1]) + assert [(r["upper"], r["lower"], r["soil_thickness_m"]) for r in rows] == [ + ("colluvium", None, None), # 토사 측점 · 두께 없음 = 전부 위 지층 + ("colluvium", "hard_rock", 2.5), # 20 = 두 구간 끝 · 뒤 구간이 이김 + ("weathered_soil", "hard_rock", 2.5), + ("weathered_soil", "weathered_rock", 0.5), # 빈 구간 = B06 그대로 + ] + assert [r["zoned"] for r in rows] == [True, True, True, True] + assert model.resolve_stations(RESOLVE_CASES[2])[0]["lower"] == "soft_rock" + + +@pytest.fixture +def project(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY", "source_project_id": None} + + async def fake_stations(_connection, _ids): + return copy.deepcopy(STATIONS) + + class FakePool: + def acquire(self): + class Ctx: + async def __aenter__(self): + return None + + async def __aexit__(self, *_): + return False + + return Ctx() + + monkeypatch.setattr(router, "_project", fake_project) + monkeypatch.setattr(router, "_b06_stations", fake_stations) + monkeypatch.setattr(router, "get_db_pool", lambda: FakePool()) + return tmp_path + + +PID = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" +SESSION = {"role": "USER", "company_id": 1} + + +def test_저장_다시읽음_초기화(project): + from uuid import UUID + + pid = UUID(PID) + first = asyncio.run(router.get_ground(pid, SESSION)) + assert first["saved"] is False and first["editable"] is True + assert first["ground"]["stations"] == STATIONS + assert model.initial_path(project).is_file() and not model.ground_path(project).is_file() + + ground = copy.deepcopy(first["ground"]) + ground["materials"].append( + { + "id": "user_1", + "name": " 점토층 ", + "gamma_t": 16.5, + "gamma_sat": 17.5, + "c": 20, + "phi": 18, + "water": "soil", + "extra": 1, + } + ) + ground["materials"][0]["c"] = 12.0 + ground["zones"] = [ + {"from_m": 0, "to_m": 30, "soil": "user_1", "rock": None, "soil_thickness_m": 1.2} + ] + ground["safety_factors"]["wet"]["cut"] = 1.3 + ground["stations"] = [] # 브라우저 값은 안 받음 + asyncio.run(router.put_ground(pid, router.GroundRequest(ground=ground))) + + stored = json.loads(model.ground_path(project).read_text(encoding="utf-8")) + assert stored["materials"][-1] == { + "id": "user_1", + "name": "점토층", + "gamma_t": 16.5, + "gamma_sat": 17.5, + "c": 20, + "phi": 18, + "water": "soil", + } + assert stored["materials"][0]["c"] == 12.0 + assert stored["safety_factors"]["wet"]["cut"] == 1.3 + assert stored["stations"] == STATIONS + assert set(stored) == set(model.GROUND_KEYS) + for item in stored["materials"]: # 엔진이 바로 읽음 + types.material(item) + + again = asyncio.run(router.get_ground(pid, SESSION)) + assert again["saved"] is True and again["ground"] == stored + + bad = copy.deepcopy(stored) + bad["materials"][0]["phi"] = 75 + with pytest.raises(HTTPException) as caught: + asyncio.run(router.put_ground(pid, router.GroundRequest(ground=bad))) + assert caught.value.status_code == 422 + assert caught.value.detail["errors"] == [{"path": "materials.0.phi", "code": "range"}] + assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == stored + + reset = asyncio.run(router.reset_ground(pid)) + initial = json.loads(model.initial_path(project).read_text(encoding="utf-8")) + assert reset["ground"] == initial == model.build_initial(DEFAULTS, STATIONS) + assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == initial + + +def test_다른_회사는_보기만(project): + from uuid import UUID + + body = asyncio.run(router.get_ground(UUID(PID), {"role": "USER", "company_id": 2})) + assert body["editable"] is False + admin = asyncio.run(router.get_ground(UUID(PID), {"role": "SYSTEM_ADMIN", "company_id": None})) + assert admin["editable"] is True diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 042d85609..e0ade76ab 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -30,6 +30,7 @@ import { ui_locales_b5 } from "./ui_template_locale_b5"; 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"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -68,6 +69,7 @@ export const ui_locales = { ...ui_locales_m1, ...ui_locales_m2, ...ui_locales_de, + ...ui_locales_e, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts new file mode 100644 index 000000000..1fad828b7 --- /dev/null +++ b/ui_template/ui_template_locale_e.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * ui_template_locale_e.ts + * 다국어 사전 — E 안정성평가 페이지 본문(E02 지반 조건 · 요구 347-2 · 3 · 4) + * + * 신규 문구는 해당 섹션 최하단에 추가. 단계 막대 · 등록 화면 문구는 ui_template_locale_de.ts. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e = { + /* --- E02 지반 조건 — 카드 --- */ + E02_Card_Materials: ["지반물성치", "Material Properties"], + E02_Card_Zones: ["층 두께 · 구간 덮어쓰기", "Layer Thickness · Zone Overrides"], + E02_Card_Stations: ["측점별 적용 지층", "Layers per Station"], + E02_Card_Safety: ["기준안전율", "Required Factors of Safety"], + E02_Card_Loads: ["노면 하중 · 지하수 · 지진", "Surcharge · Groundwater · Seismic"], + E02_Note: [ + "문헌 추정치 · 현장 시험 없음 — 값은 바꿀 수 있습니다. 칸에 마우스를 올리면 근거가 보입니다.", + "Literature estimates without field tests — values are editable. Hover a cell for its source.", + ], + + /* --- 물성 표 --- */ + E02_Col_Name: ["지층", "Layer"], + E02_Col_GammaT: ["γt (kN/m³)", "γt (kN/m³)"], + E02_Col_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"], + E02_Col_C: ["c (kPa)", "c (kPa)"], + E02_Col_Phi: ["φ (°)", "φ (°)"], + E02_Col_Water: ["지하수 무리", "Water group"], + E02_Water_Soil: ["토사 · 풍화암", "Soil · weathered rock"], + E02_Water_Rock: ["암반", "Rock"], + E02_Btn_AddLayer: ["지층 추가", "Add layer"], + E02_Btn_Defaults: ["기본값", "Defaults"], + E02_Btn_Remove: ["지움", "Remove"], + E02_NewLayer: ["새 지층", "New layer"], + E02_InUse: [ + "쓰이는 지층은 지울 수 없습니다(B06 지반 연결 · 구간).", + "A layer in use cannot be removed (B06 mapping · zones).", + ], + E02_LabelMap: ["B06 지반 → 지층", "B06 ground → layer"], + E02_Label_soil: ["토사 (경계 위)", "Soil (above boundary)"], + E02_Label_ripping_rock: ["리핑암 (경계 아래)", "Ripping rock (below)"], + E02_Label_blasting_rock: ["발파암 (경계 아래)", "Blasting rock (below)"], + E02_Label_fill: ["성토체", "Embankment"], + + /* --- 구간 · 측점 표 --- */ + E02_Col_From: ["시작 (m)", "From (m)"], + E02_Col_To: ["끝 (m)", "To (m)"], + E02_Col_Upper: ["위 지층", "Upper layer"], + E02_Col_Lower: ["아래 지층", "Lower layer"], + E02_Col_Thickness: ["토층 두께 (m)", "Soil thickness (m)"], + E02_Col_Station: ["측점", "Station"], + E02_Col_GroundType: ["B06 지반", "B06 ground"], + E02_Col_B06Thickness: ["B06 토층 두께 (m)", "B06 thickness (m)"], + E02_Btn_AddZone: ["구간 추가", "Add zone"], + E02_Zone_Keep: ["B06 그대로", "Keep B06"], + E02_Zone_Empty: [ + "구간 덮어쓰기 없음 — 모든 측점이 B06 값 그대로입니다.", + "No zone overrides — every station keeps its B06 values.", + ], + E02_Zone_Hint: [ + "구간 안 측점(양 끝 포함)은 고른 지층 · 두께로 바뀝니다. 빈 칸은 B06 그대로 · 뒤 줄이 앞 줄을 이깁니다.", + "Stations inside a zone (ends included) take its layers and thickness. Blank keeps B06 · later rows win.", + ], + E02_Stations_Empty: ["B06 측점이 없습니다.", "No B06 stations."], + E02_Ground_soil: ["토사", "Soil"], + E02_Ground_ripping_rock: ["리핑암", "Ripping rock"], + E02_Ground_blasting_rock: ["발파암", "Blasting rock"], + E02_AllUpper: ["전부 위 지층", "All upper layer"], + + /* --- 기준안전율 --- */ + E02_Case_dry: ["건기", "Dry"], + E02_Case_wet: ["우기", "Wet"], + E02_Case_seismic: ["지진 시", "Seismic"], + E02_Case_short_term: ["단기", "Short-term"], + E02_Slope_cut: ["깎기", "Cut"], + E02_Slope_fill: ["쌓기", "Fill"], + E02_Slope_soft: ["쌓기(연약지반)", "Fill (soft ground)"], + + /* --- 하중 · 지하수 · 지진 --- */ + E02_Surcharge: ["노면 하중 (kPa)", "Road surcharge (kPa)"], + E02_Water_dry: ["건기 지하수", "Dry groundwater"], + E02_Water_wet_soil: ["우기 · 토사 · 풍화암", "Wet · soil · weathered rock"], + E02_Water_wet_rock: ["우기 · 암반", "Wet · rock"], + E02_Rule_none: ["없음", "None"], + E02_Rule_surface: ["지표 포화", "Saturated to surface"], + E02_Rule_half_height: ["비탈 높이 ½ 포화", "Saturated to ½ height"], + E02_Seismic: ["지진 검토", "Seismic check"], + E02_Kh: ["수평지진계수 kh", "Horizontal coefficient kh"], + + /* --- 상태 · 알림 --- */ + E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], + E02_Saved: ["저장했습니다.", "Saved."], + E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."], + E02_ResetConfirm: [ + "초기값(생성 때 기본값 + B06 측점)으로 되돌릴까요? 저장한 값이 사라집니다.", + "Restore initial values (defaults + B06 at creation)? Saved values will be lost.", + ], + E02_Invalid: ["입력값을 확인하세요 — 틀린 칸", "Check inputs — invalid cells"], + E02_DefaultIs: ["기본값", "Default"], + E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"], + E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + + /* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */ + E02_Err_number: ["숫자를 넣으세요", "Enter a number"], + E02_Err_range: ["범위 밖", "Out of range"], + E02_Err_order: ["끝은 시작보다 커야 합니다", "End must exceed start"], + E02_Err_sat_lt_t: ["γsat 는 γt 이상이어야 합니다", "γsat must be ≥ γt"], + E02_Err_name: ["이름은 1~30자", "Name must be 1–30 characters"], + E02_Err_name_dup: ["같은 이름이 있습니다", "Duplicate name"], + E02_Err_id_dup: ["지층 번호가 겹칩니다", "Duplicate layer id"], + E02_Err_enum: ["값을 고르세요", "Pick a value"], + E02_Err_ref: ["지층을 고르세요", "Pick a layer"], + E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"], + E02_Err_type: ["꼴이 틀립니다", "Invalid shape"], +} as const satisfies Record;