feat(532-3): E02 지반 조건 — 기본값 정본 · 물성 표(지층 추가 · 지움) · 구간 덮어쓰기 · KDS 기준안전율 · 노면 하중 · 지하수 · 지진 · 저장 · 초기화

- 기본값 정본 resources/master_template/stability/ground_defaults.json — 지층 여섯 · 값마다 출처(07 표 · 절) · KDS 11 70 05 표 4.3-1~3 · 노면 10 kPa · 지하수 규칙 · 지진 끔 · 범위
- 07 원문 대조 — 성토 φ 25 → 28(07 §4 종합 28~30 아래값) · 나머지 범위 안
- API GET · PUT /api/projects/{id}/stab/ground · POST …/reset — 작업본 E02_StabGround/ground.json · 초기값 initial_snapshot/E02_StabGround/ground.json(E 생성 직후 기본값 + B06 측점 암 경계) · 측점은 서버 값
- 검사 · 측점 적용 = 파이썬 · TS 짝(E02_StabGround_Model.py ↔ logic/E02_StabGround_Check.ts) · 거울 시험
- 화면 — 카드 넷 · 칸 위 근거 · 기본값 툴팁 · 틀린 칸 표시 · sessionStorage 캐시 · 자동저장 없음
- test_528 E01 시험 — 생성 뒤 E02 초기값 부름 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:00:44 +09:00
co-authored by Claude Opus 5.5
parent 39e8ee837e
commit f0c5232f80
14 changed files with 1946 additions and 4 deletions
@@ -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}
+230
View File
@@ -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
+156
View File
@@ -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}
+244
View File
@@ -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<GroundResponse> {
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<HTMLElement>(".is-invalid")) {
node.classList.remove("is-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[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<void> {
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<void> {
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"),
}),
);
}
})();
}
+153
View File
@@ -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);
}
+488
View File
@@ -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 = <T>(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 };
}
+3 -4
View File
@@ -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,
};
@@ -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<string, [number, number]>;
export interface GroundDefaults extends Ground {
note: string;
limits: Limits;
sources: Record<string, string>;
}
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<string, unknown>;
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,
};
});
}
+2
View File
@@ -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)
@@ -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 · 물량 가름용 값) — 토사 측점은 경계 없음"
}
}
@@ -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):
+285
View File
@@ -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
+2
View File
@@ -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;
+116
View File
@@ -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<string, LocaleEntry>;