Files
Aislo/E02_StabGround/E02_StabGround_Router.py
eomsangdonandClaude Opus 5.5 8f7bb8e7e6 feat(564-1): E02 한 번에 해석 체인 — 지반 저장 → 2D → 3D 건기 · 우기 → 암반 → 광역 → 보고서 · 진행 · 끊기
- 새 E02_StabGround_Chain.py — 페이지 해석 함수 그대로 부름 · 지문 같은 단계 건너뜀 · 단계 실패 격리 · 페이지 _JOBS 꽂음 · 3D 진행률 = 구간 × 조건 수로 나눔
- E02 Router 길 셋(POST /stab/run · GET /stab/run/progress · POST /stab/run/cancel) · PUT 과 같은 저장 함수
- 3D 열쇠 = section_key(start, end, condition) 꼴(564-2 뒤 실제 연결) · 단계 상태 적기 자리 = 565-1 뒤
- 새 시험 test_564_1_e02_chain.py(차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 · 3D 조각 · 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:57:52 +09:00

186 lines
7.8 KiB
Python

"""E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3).
- `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지
+ 횡단 없는 측점(`missing_stations` · 화면 띠)
- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json`
· 측점(B06 암 경계)은 서버 값
- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
- `POST …/stab/run` — [해석 시작] 지반 저장 → 한 번에 해석 체인(`E02_StabGround_Chain` · PLAN 564-1)
· `GET …/stab/run/progress` · `POST …/stab/run/cancel`
초기값 = 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 common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_copy import MANIFEST_NAME
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 E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir
from E02_StabGround import E02_StabGround_Chain as chain
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]
def _b06_stations(project_root: Path) -> list[dict]:
"""측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음).
E01 동결 복사본(`E01_StabRegister/source/db.json`)만 읽음 — 복사본이 없으면 빈 목록.
"""
root = source_dir(project_root)
if not (root / MANIFEST_NAME).is_file():
return []
rows = sorted(db_snapshot(root)["cross_sections"], key=lambda r: float(r["chainage_m"] or 0))
stations = []
for row in rows:
data = json.loads(row["data"]) if isinstance(row["data"], str) else row["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(row["chainage_m"]), 3),
"ground_type": ground_type,
"soil_thickness_m": round(abs(float(offset)), 3) if rock else None,
}
)
return stations
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 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
def ensure_initial(project_root: Path) -> dict:
"""초기값이 없을 때만 한 번 — 기본값 + 복사본 B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET)."""
path = model.initial_path(project_root)
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
ground = model.build_initial(model.load_defaults(), _b06_stations(project_root))
atomic_write_json(path, ground)
return ground
async def seed_initial(project_id: str) -> None:
"""E01 생성 직후 부름 — 초기값 한 벌."""
root, _ = await _project(UUID(project_id))
ensure_initial(root)
def _missing(project_root: Path) -> list[float]:
"""횡단 없어 해석에서 빠진 측점 — 복사본이 없으면 빈 목록."""
root = source_dir(project_root)
return missing_stations(root) if (root / MANIFEST_NAME).is_file() else []
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 = ensure_initial(root)
working = _read_working(root)
defaults = model.load_defaults()
return {
# 옛 정본(지하수 갈래 칸 전) = 빠진 칸만 기본값으로 채워 보냄(파일은 [저장] 때 씀)
"ground": model.upgrade(working or initial, defaults),
"saved": working is not None,
"defaults": defaults,
"editable": _editable(session, project),
"missing_stations": _missing(root),
}
def _save(root: Path, payload: dict[str, Any]) -> dict[str, Any]:
"""검사 뒤 작업본 쓰기 — 틀리면 422."""
defaults = model.load_defaults()
errors = model.check_ground(payload, defaults["limits"])
if errors:
raise HTTPException(status_code=422, detail={"errors": errors})
ground = model.clean(payload)
# 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음
base = _read_working(root) or ensure_initial(root)
ground["stations"] = base.get("stations", [])
ground["version"] = defaults["version"]
atomic_write_json(model.ground_path(root), ground)
return ground
@router.put("/{project_id}/stab/ground")
async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
return {"ground": _save(root, payload.ground)}
@router.post("/{project_id}/stab/ground/reset")
async def reset_ground(project_id: UUID) -> dict[str, Any]:
root, _ = await _project(project_id)
initial = ensure_initial(root)
atomic_write_json(model.ground_path(root), initial)
return {"ground": model.upgrade(initial, model.load_defaults())}
@router.post("/{project_id}/stab/run")
async def run_chain(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
"""[해석 시작] — 지반 저장 → 체인(뒤에서) · 진행은 `GET …/stab/run/progress`."""
root, _ = await _project(project_id)
if chain.busy(project_id):
raise HTTPException(status_code=409, detail="이미 해석 중입니다.")
ground = _save(root, payload.ground)
return {"ground": ground, **chain.start(project_id, root)}
@router.get("/{project_id}/stab/run/progress")
async def run_progress(project_id: UUID) -> dict[str, Any]:
await _project(project_id)
return chain.progress(project_id)
@router.post("/{project_id}/stab/run/cancel")
async def run_cancel(project_id: UUID) -> dict[str, Any]:
"""[끊기] — 다음 확인 때 멈춤 · 끝난 단계 저장본은 그대로."""
await _project(project_id)
return {"stopping": chain.cancel(project_id)}