Files
Aislo/B07_DesignDetail/B07_DesignDetail_Router.py
T
eomsangdonandClaude Opus 5 76201631ee feat(b07): 수량 산출 프로토타입 페이지를 붙인다 (처분 가능)
- B07_DesignDetail_Quantity_Proto.py 신규 — 정본 3곳(pipe_points·structures·횡단 patch)
  일람 + 실무 원단위(참조 전용) 매칭 rollup을 서버 렌더 HTML로 제공
- 경로: /api/projects/quantity-proto/page[/{project_id}]
- 라우터 include 2줄 — 파일 삭제 시 함께 제거하면 흔적 없음
- 원단위 출처: 울진1 수량집계·울진소광 구조도 숨김탭 (채용 미확정, 평가용)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:41:11 +09:00

363 lines
16 KiB
Python

"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
import asyncio
import logging
import re
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_design,
get_cross_section_designs,
get_longitudinal_section,
merge_cross_section_design_by_round,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
CROSS_SHEET_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
extract_quantity_table,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
MASS_HAUL_ID,
WATERSHED_ID,
_cross_sheet_plan,
_drawing_list,
_invalidate_drawing,
_read_drawing,
_recompute_confirmed_design,
_store_confirmed_drawing,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
DesignDrawingConfirmResponse,
DesignDrawingInvalidateResponse,
DesignDrawingListResponse,
DesignDrawingResponse,
)
from common_util.common_util_drainage_context import load_drainage_context
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
# 수량 산출 프로토타입 (처분 가능) — 파일 삭제 시 아래 2줄도 함께 지운다.
from B07_DesignDetail.B07_DesignDetail_Quantity_Proto import proto_router # noqa: E402
router.include_router(proto_router)
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
"""확정된 B06 종단 레코드와 프로젝트 저장 경로를 반환한다."""
pool = get_db_pool()
async with pool.acquire() as connection:
route_context = await get_confirmed_route_context(connection, project_id)
if not route_context:
raise FileNotFoundError("확정된 경로가 없습니다.")
route_id = int(route_context["route_id"])
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal or longitudinal.get("status") != "CONFIRMED":
raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.")
stored_path = await get_project_storage_relative_path(connection, project_id)
root = Path(resolve_stored_project_path(stored_path)).resolve()
longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.")
return route_id, root, longitudinal_path
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await get_cross_section_designs(connection, route_id)
return {
int(round(float(row["chainage_m"]))): row["design"]
for row in rows
if isinstance(row, dict) and isinstance(row.get("design"), dict)
}
@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse)
async def get_design_drawing_list(
project_id: UUID,
) -> DesignDrawingListResponse | JSONResponse:
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B07 도면 목록 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 도면 목록을 읽지 못했습니다."},
)
@router.get("/{project_id}/design-drawings/{drawing_id}", response_model=DesignDrawingResponse)
async def get_design_drawing(
project_id: UUID, drawing_id: str
) -> DesignDrawingResponse | JSONResponse:
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
source_design: Any = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
if cross_match:
pool = get_db_pool()
async with pool.acquire() as connection:
design = await get_cross_section_design(
connection, route_id, int(cross_match.group(1))
)
source_design = design
elif CROSS_SHEET_ID.fullmatch(drawing_id):
# 장은 여러 측점을 담으므로 노선 전체 지정을 한 번에 읽어 넘긴다.
source_design = await _designs_by_chainage(route_id)
elif drawing_id == MASS_HAUL_ID:
# 유토곡선은 B06 확정 시 종단 레코드에 저장해 둔 산출물을 그대로 쓴다.
pool = get_db_pool()
async with pool.acquire() as connection:
section = await get_longitudinal_section(connection, project_id, route_id)
source_design = ((section or {}).get("data") or {}).get("mass_haul")
elif drawing_id == WATERSHED_ID:
# 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다.
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
source_design = await asyncio.to_thread(watershed_source, context)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
)
return DesignDrawingResponse(
project_id=str(project_id),
route_id=route_id,
id=drawing_id,
kind=kind,
label=label,
drawing=drawing,
confirmed=confirmed,
quantity_table=quantity_table,
design=design,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B07 단건 도면 조회 실패: project_id=%s drawing_id=%s", project_id, drawing_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 도면을 읽지 못했습니다."},
)
@router.put(
"/{project_id}/design-drawings/{drawing_id}/confirm",
response_model=DesignDrawingConfirmResponse,
)
async def confirm_design_drawing(
project_id: UUID, drawing_id: str, request: DesignDrawingConfirmRequest
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
"""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
if not item:
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
sheet = None
if CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
# 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}").
quantity_table = None
quantity_tables: dict[str, Any] = {}
if sheet is not None:
for chainage in sheet["chainages"]:
table = extract_quantity_table(f"{drawing_id}:{chainage}", request.drawing)
if table:
quantity_tables[str(chainage)] = table
elif item.kind == "cross":
quantity_table = (
extract_quantity_table(drawing_id, request.drawing) or request.quantity_table
)
# 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관.
all_confirmed = await asyncio.to_thread(
_store_confirmed_drawing,
project_root,
item,
request.drawing,
{candidate.id for candidate in items if candidate.kind == "cross"},
quantity_table,
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
if sheet is not None:
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, confirmed_design in recomputed:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
)
async with connection.cursor() as cursor:
if all_confirmed:
await complete_stage(cursor, str(project_id), 4)
else:
await start_stage(cursor, str(project_id), 4)
await connection.commit()
except Exception:
await connection.rollback()
raise
return DesignDrawingConfirmResponse(
project_id=str(project_id),
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=recomputed[0][1] if len(recomputed) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B07 도면 확정 실패: project_id=%s drawing_id=%s", project_id, drawing_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 도면을 확정하지 못했습니다."},
)
@router.post(
"/{project_id}/design-drawings/{drawing_id}/invalidate",
response_model=DesignDrawingInvalidateResponse,
)
async def invalidate_design_drawing(
project_id: UUID, drawing_id: str
) -> DesignDrawingInvalidateResponse | JSONResponse:
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
if drawing_id not in {item.id for item in items}:
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
stale: list[int] = []
if cross_match:
stale = [int(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
designs = await _designs_by_chainage(route_id)
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
stale = list(sheet["chainages"]) if sheet else []
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
for chainage_int in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 4)
await connection.commit()
except Exception:
await connection.rollback()
raise
return DesignDrawingInvalidateResponse(
project_id=str(project_id),
id=drawing_id,
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B07 도면 확정 해제 실패: project_id=%s drawing_id=%s", project_id, drawing_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."},
)