관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.
공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)
B04 신규 API
- GET /{project_id}/drainage/pipe-points 저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT /{project_id}/drainage/pipe-points 모델 확정 시 관 지점·세부유역 커밋
B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
678 lines
31 KiB
Python
678 lines
31 KiB
Python
"""B06 종횡단 생성 FastAPI 라우터."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Body, Depends
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
|
|
cross_filename,
|
|
prune_stale_cross_files,
|
|
run_section_generation,
|
|
)
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
|
from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
|
confirm_sections_for_route,
|
|
count_cross_sections,
|
|
create_longitudinal_section,
|
|
delete_sections_for_route,
|
|
get_confirmed_route_context,
|
|
get_cross_section_designs,
|
|
get_cross_sections_missing_design_chainages,
|
|
get_latest_section_options,
|
|
get_longitudinal_section,
|
|
get_project_standard_cross_section,
|
|
get_route_generation_source,
|
|
insert_cross_sections,
|
|
list_recent_company_projects,
|
|
merge_cross_section_design_patch,
|
|
merge_longitudinal_section_options,
|
|
update_cross_section_design,
|
|
)
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
|
CompanyStandardListResponse,
|
|
CompanyStandardProject,
|
|
CompanyStandardResponse,
|
|
CrossDesignRequest,
|
|
CrossDesignResponse,
|
|
SectionConfirmRequest,
|
|
SectionConfirmResponse,
|
|
SectionContextResponse,
|
|
SectionDetailResponse,
|
|
SectionOptionDefaults,
|
|
SectionRegenerateRequest,
|
|
SectionSummaryResponse,
|
|
)
|
|
from common_util.common_util_auth import verify_session
|
|
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
|
from common_util.common_util_workflow_state import complete_stage, get_workflow_state
|
|
from config.config_db import get_db_pool
|
|
from config.config_system import (
|
|
FOREST_ROAD_MIN_WIDTH_M,
|
|
SECTION_VERTICAL_EXAGGERATION,
|
|
STANDARD_CROSS_SECTION,
|
|
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
|
STANDARD_ROCK_BOUNDARY_STEP_M,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
|
|
|
|
|
@router.get("/{project_id}/sections/context", response_model=SectionContextResponse)
|
|
async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse:
|
|
"""최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
route_context = await get_confirmed_route_context(connection, project_id)
|
|
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
|
|
|
defaults = SectionGenerationOptions()
|
|
return SectionContextResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_context["route_id"] if route_context else None,
|
|
filter_key=surface_params["source_filter"] if route_context else None,
|
|
method=surface_params["method"] if route_context else None,
|
|
smooth=bool(surface_params["smooth"]) if route_context else None,
|
|
crs_epsg=route_context["crs_epsg"] if route_context else None,
|
|
defaults=SectionOptionDefaults(
|
|
station_interval_m=defaults.station_interval_m,
|
|
cross_half_width_m=defaults.cross_half_width_m,
|
|
cross_sample_interval_m=defaults.cross_sample_interval_m,
|
|
long_sample_interval_m=defaults.long_sample_interval_m,
|
|
vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION,
|
|
),
|
|
standard_cross_section=STANDARD_CROSS_SECTION,
|
|
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
|
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
|
|
)
|
|
except Exception:
|
|
logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 컨텍스트 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/sections/road-widths")
|
|
async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, float]]:
|
|
"""B05 측점 가로선에 적용할 임도 등급별 법정 최소너비를 반환한다."""
|
|
return {"forest_road_min_width_m": FOREST_ROAD_MIN_WIDTH_M}
|
|
|
|
|
|
# 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다.
|
|
@router.get("/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse)
|
|
async def list_company_standards(
|
|
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
|
) -> CompanyStandardListResponse:
|
|
"""같은 회사의 최근 프로젝트 5개를 반환한다(설계값 보유 여부 무관)."""
|
|
company_id = session.get("company_id")
|
|
if company_id is None:
|
|
return CompanyStandardListResponse(projects=[])
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
rows = await list_recent_company_projects(connection, company_id, project_id)
|
|
return CompanyStandardListResponse(
|
|
projects=[
|
|
CompanyStandardProject(project_id=str(row["project_id"]), name=row["name"])
|
|
for row in rows
|
|
]
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{project_id}/sections/company-standards/{source_project_id}",
|
|
response_model=CompanyStandardResponse,
|
|
)
|
|
async def get_company_standard(
|
|
project_id: UUID,
|
|
source_project_id: UUID,
|
|
session: dict[str, Any] = Depends(verify_session),
|
|
) -> CompanyStandardResponse | JSONResponse:
|
|
"""특정 프로젝트의 표준횡단 설정값을 미리보기용으로 반환한다(회사 스코프 강제)."""
|
|
company_id = session.get("company_id")
|
|
if company_id is None:
|
|
return JSONResponse(
|
|
status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
|
|
)
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
standard = await get_project_standard_cross_section(
|
|
connection, company_id, source_project_id
|
|
)
|
|
if standard is None:
|
|
return JSONResponse(
|
|
status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
|
|
)
|
|
return CompanyStandardResponse(
|
|
project_id=str(source_project_id), standard_cross_section=standard
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
|
|
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
|
|
"""경로의 종단면 요약을 조회한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
|
cross_section_count = await count_cross_sections(connection, route_id)
|
|
data = longitudinal.get("data") if longitudinal else None
|
|
length_m = data.get("length_m") if isinstance(data, dict) else None
|
|
return SectionSummaryResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
longitudinal=longitudinal,
|
|
length_m=length_m,
|
|
cross_section_count=cross_section_count,
|
|
)
|
|
except Exception:
|
|
logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dict:
|
|
"""검증된 프로젝트 루트 안의 종단 및 횡단 JSON을 읽는다."""
|
|
root = project_root.resolve()
|
|
longitudinal_path = (root / longitudinal_file_path).resolve()
|
|
if root not in longitudinal_path.parents:
|
|
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
|
|
if not longitudinal_path.is_file():
|
|
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
|
|
|
|
stage_root = longitudinal_path.parent.parent
|
|
cross_dir = stage_root / "cross_sections"
|
|
if not cross_dir.is_dir():
|
|
raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.")
|
|
|
|
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
|
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
|
|
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
|
cross_sections = [
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
for path in sorted(cross_dir.glob("cross_*.json"))
|
|
if not valid_names or path.name in valid_names
|
|
]
|
|
if not isinstance(longitudinal, dict) or not all(
|
|
isinstance(section, dict) for section in cross_sections
|
|
):
|
|
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
|
|
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
|
|
|
|
|
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
|
|
async def get_section_detail(
|
|
project_id: UUID, route_id: int
|
|
) -> SectionDetailResponse | JSONResponse:
|
|
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
|
if not longitudinal:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
|
)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
designs = await get_cross_section_designs(connection, route_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
detail = await asyncio.to_thread(
|
|
_read_section_detail,
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
)
|
|
# DB에만 있는 잠정 설계 지정을 chainage 근사로 각 횡단에 얹어 화면 복원을 돕는다.
|
|
for record in designs:
|
|
for section in detail["cross_sections"]:
|
|
if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01:
|
|
section["design"] = record["design"]
|
|
break
|
|
# 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다.
|
|
# (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.)
|
|
await asyncio.to_thread(
|
|
_attach_default_designs, detail["longitudinal"], detail["cross_sections"]
|
|
)
|
|
return SectionDetailResponse(**detail)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
logger.warning(
|
|
"B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s",
|
|
project_id,
|
|
route_id,
|
|
exc,
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 상세 파일을 읽지 못했습니다."},
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"B06 종횡단 상세 조회 실패: project_id=%s route_id=%s", project_id, route_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 상세 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
def _regeneration_options(
|
|
stored_options: dict[str, Any] | None,
|
|
stage_params: dict[str, Any] | None,
|
|
cross_half_width_m: float,
|
|
) -> SectionGenerationOptions:
|
|
"""DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다."""
|
|
defaults = SectionGenerationOptions()
|
|
stored = stored_options or {}
|
|
params = stage_params or {}
|
|
|
|
def pick(key: str, default: float) -> float:
|
|
return stored.get(key) or params.get(key) or default
|
|
|
|
return SectionGenerationOptions(
|
|
station_interval_m=pick("station_interval_m", defaults.station_interval_m),
|
|
cross_half_width_m=cross_half_width_m,
|
|
cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m),
|
|
long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m),
|
|
include_endpoint=defaults.include_endpoint,
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/sections/{route_id}/regenerate", response_model=SectionDetailResponse)
|
|
async def regenerate_sections(
|
|
project_id: UUID, route_id: int, request: SectionRegenerateRequest
|
|
) -> SectionDetailResponse | JSONResponse:
|
|
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
source = await get_route_generation_source(connection, project_id, route_id)
|
|
if not source:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "재생성할 경로가 없습니다."},
|
|
)
|
|
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
stored_options = await get_latest_section_options(connection, project_id)
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
workflow = await get_workflow_state(cursor, str(project_id))
|
|
route_stage = next(
|
|
(stage for stage in workflow["stages"] if stage["stage_no"] == 2),
|
|
None,
|
|
)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
crs_epsg = source["crs_epsg"]
|
|
sections = await asyncio.to_thread(
|
|
run_section_generation,
|
|
project_root,
|
|
source["route_data_path"],
|
|
surface_params["source_filter"],
|
|
surface_params["method"],
|
|
bool(surface_params["smooth"]),
|
|
options=_regeneration_options(
|
|
stored_options,
|
|
route_stage.get("params") if route_stage else None,
|
|
request.cross_half_width_m,
|
|
),
|
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
await delete_sections_for_route(connection, route_id)
|
|
await create_longitudinal_section(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
data=sections["longitudinal"]["data"],
|
|
longitudinal_file_path=sections["longitudinal"]["file_path"],
|
|
)
|
|
await insert_cross_sections(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
sections=sections["cross_sections"],
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
result = sections["result"]
|
|
return SectionDetailResponse(
|
|
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B06 종횡단 재생성 실패: project_id=%s route_id=%s", project_id, route_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 재생성 처리 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
def _read_cross_design_inputs(
|
|
project_root: Path, longitudinal_file_path: str, chainage_m: float
|
|
) -> tuple[list[dict], float | None, bool]:
|
|
"""측점 하나의 지반 샘플·계획고·포장 제안 여부를 파일에서 읽는다 (경로 이탈 검증 포함)."""
|
|
root = project_root.resolve()
|
|
longitudinal_path = (root / longitudinal_file_path).resolve()
|
|
if root not in longitudinal_path.parents:
|
|
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
|
|
if not longitudinal_path.is_file():
|
|
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
|
|
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
|
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
|
|
pavement_suggested = _pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
|
|
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
|
|
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
|
|
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
|
|
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
|
samples = cross.get("samples") if isinstance(cross, dict) else None
|
|
if not isinstance(samples, list):
|
|
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
|
return samples, design_elevation, pavement_suggested
|
|
|
|
|
|
def _pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
|
|
"""측점별 포장 제안(B05 solve가 법정 경사 기준으로 판정) 매핑을 만든다."""
|
|
stations = longitudinal.get("stations")
|
|
mapping: dict[float, bool] = {}
|
|
if isinstance(stations, list):
|
|
for station in stations:
|
|
suggested = station.get("pavement_suggested")
|
|
if isinstance(suggested, bool):
|
|
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
|
|
return mapping
|
|
|
|
|
|
def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
|
|
"""측점별 기본 단면유형 매핑: 상단측(uphill_side)이 절토측이 되는 편절편성.
|
|
|
|
B05 solve가 자동 판정하고 사용자가 3D 램프로 바꾼 값(확정 시 정본 병합)을 그대로
|
|
소비한다. 판정 불가 측점은 매핑에서 빠지고 호출부가 좌절토로 폴백한다.
|
|
"""
|
|
stations = longitudinal.get("stations")
|
|
mapping: dict[float, str] = {}
|
|
if isinstance(stations, list):
|
|
for station in stations:
|
|
side = station.get("uphill_side")
|
|
if side in ("left", "right"):
|
|
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
|
|
return mapping
|
|
|
|
|
|
def _attach_default_designs(
|
|
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
|
|
) -> None:
|
|
"""지정 설계가 없는 횡단에 기본값(토사 + 상단측 절토) 프리뷰 설계를 즉석 계산해 얹는다.
|
|
|
|
detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이
|
|
전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다.
|
|
"""
|
|
default_modes = _default_section_modes(longitudinal)
|
|
pavement = _pavement_suggestions(longitudinal)
|
|
for section in cross_sections:
|
|
if section.get("design"):
|
|
continue
|
|
try:
|
|
chainage_m = float(section.get("chainage_m", 0.0))
|
|
suggested = pavement.get(round(chainage_m, 3), False)
|
|
design = compute_cross_design(
|
|
section.get("samples", []),
|
|
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
|
ground_type="soil",
|
|
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
|
paved=suggested,
|
|
)
|
|
design["status"] = "provisional"
|
|
design["pavement_suggested"] = suggested
|
|
section["design"] = design
|
|
except (ValueError, KeyError):
|
|
continue
|
|
|
|
|
|
def _compute_default_designs(
|
|
project_root: Path,
|
|
longitudinal_file_path: str,
|
|
chainages: list[float],
|
|
standard: dict[str, Any] | None = None,
|
|
) -> list[tuple[float, dict[str, Any]]]:
|
|
"""미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다.
|
|
|
|
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
|
|
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
|
|
"""
|
|
root = project_root.resolve()
|
|
longitudinal_path = (root / longitudinal_file_path).resolve()
|
|
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
|
|
return []
|
|
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
default_modes = _default_section_modes(longitudinal)
|
|
pavement = _pavement_suggestions(longitudinal)
|
|
results: list[tuple[float, dict[str, Any]]] = []
|
|
for chainage_m in chainages:
|
|
cross_path = cross_dir / cross_filename(chainage_m)
|
|
if not cross_path.is_file():
|
|
continue
|
|
try:
|
|
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
|
samples = cross.get("samples")
|
|
if not isinstance(samples, list):
|
|
continue
|
|
suggested = pavement.get(round(chainage_m, 3), False)
|
|
design = compute_cross_design(
|
|
samples,
|
|
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
|
ground_type="soil",
|
|
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
|
paved=suggested,
|
|
standard=standard,
|
|
)
|
|
design["status"] = "provisional"
|
|
design["pavement_suggested"] = suggested
|
|
results.append((chainage_m, design))
|
|
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
|
continue
|
|
return results
|
|
|
|
|
|
@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse)
|
|
async def compute_cross_section_design(
|
|
project_id: UUID, route_id: int, request: CrossDesignRequest
|
|
) -> CrossDesignResponse | JSONResponse:
|
|
"""측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산해 잠정치로 저장한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
|
if not longitudinal:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
|
)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
samples, design_elevation, pavement_suggested = await asyncio.to_thread(
|
|
_read_cross_design_inputs,
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
request.chainage_m,
|
|
)
|
|
design = compute_cross_design(
|
|
samples,
|
|
design_elevation,
|
|
ground_type=request.ground_type,
|
|
section_mode=request.section_mode,
|
|
ditch_side=request.ditch_side,
|
|
ditch_type=request.ditch_type,
|
|
paved=request.paved,
|
|
standard=request.standard_cross_section,
|
|
rock_boundary_offset_m=request.rock_boundary_offset_m,
|
|
two_stage_slope=request.two_stage_slope,
|
|
ditch_enabled=request.ditch_enabled,
|
|
)
|
|
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
|
design["status"] = "provisional"
|
|
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
|
|
design["pavement_suggested"] = pavement_suggested
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
updated = await update_cross_section_design(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=request.chainage_m,
|
|
design=design,
|
|
project_id=project_id,
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
if not updated:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "해당 측점 횡단 레코드가 없습니다."},
|
|
)
|
|
return CrossDesignResponse(chainage_m=request.chainage_m, design=design)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception(
|
|
"B06 측점 횡단 설계 계산 실패: project_id=%s route_id=%s", project_id, route_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
|
|
async def confirm_sections(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
request: SectionConfirmRequest | None = Body(default=None),
|
|
) -> SectionConfirmResponse | JSONResponse:
|
|
"""경로의 종횡단면을 확정(CONFIRMED)한다.
|
|
|
|
지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다.
|
|
표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다.
|
|
"""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
existing = await get_longitudinal_section(connection, project_id, route_id)
|
|
if not existing:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
|
|
)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
|
|
|
# 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀).
|
|
default_designs: list[tuple[float, dict[str, Any]]] = []
|
|
if missing:
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
default_designs = await asyncio.to_thread(
|
|
_compute_default_designs,
|
|
project_root,
|
|
str(existing["longitudinal_file_path"]),
|
|
missing,
|
|
request.standard_cross_section if request else None,
|
|
)
|
|
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
for chainage_m, design in default_designs:
|
|
await update_cross_section_design(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=chainage_m,
|
|
design=design,
|
|
project_id=project_id,
|
|
)
|
|
if request and request.standard_cross_section:
|
|
await merge_longitudinal_section_options(
|
|
connection,
|
|
route_id=route_id,
|
|
options_patch={"standard_cross_section": request.standard_cross_section},
|
|
)
|
|
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
|
|
if request and request.cross_patches:
|
|
for patch_item in request.cross_patches:
|
|
patch: dict[str, Any] = {}
|
|
if patch_item.rock_boundary_offset_m is not None:
|
|
patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m
|
|
if patch:
|
|
await merge_cross_section_design_patch(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=patch_item.chainage_m,
|
|
patch=patch,
|
|
)
|
|
await confirm_sections_for_route(connection, route_id)
|
|
async with connection.cursor() as cursor:
|
|
await complete_stage(cursor, str(project_id), 3)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
|
|
# 파일 기반·비치명적: 실패해도 확정은 유지한다.
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
designs = await get_cross_section_designs(connection, route_id)
|
|
overrides = [
|
|
{"chainage_m": record["chainage_m"], "side": record["design"]["ditch_side"]}
|
|
for record in designs
|
|
if isinstance(record.get("design"), dict)
|
|
and record["design"].get("ditch_side") in ("left", "right")
|
|
]
|
|
if overrides:
|
|
await asyncio.to_thread(
|
|
_merge_uphill_overrides_into_longitudinal,
|
|
Path(resolve_stored_project_path(stored_path)),
|
|
str(existing["longitudinal_file_path"]),
|
|
overrides,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"B06 측구 방향 B05 역반영 실패 (확정은 유지): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
|
|
except Exception:
|
|
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
|
)
|