Files
Aislo/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py
T
2026-07-19 19:18:14 +09:00

561 lines
22 KiB
Python

"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
import asyncio
import json
import logging
import re
from pathlib import Path
from typing import Any
from uuid import UUID, uuid5
from fastapi import APIRouter
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 prune_stale_cross_files
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_confirmed_route_context,
get_longitudinal_section,
)
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
DesignDrawingConfirmResponse,
DesignDrawingInvalidateResponse,
DesignDrawingItem,
DesignDrawingListResponse,
DesignDrawingResponse,
)
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"])
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_GROUND_LAYER_ID = "b07-ground"
_STAGE_DIR = "B07_wf4_DesignDetail"
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
def _read_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.")
return payload
def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]:
cross_dir = longitudinal_path.parent.parent / "cross_sections"
if not cross_dir.is_dir():
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
stations = longitudinal.get("stations")
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
files = sorted(cross_dir.glob("cross_*.json"))
if valid_names:
files = [path for path in files if path.name in valid_names]
return files
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
stations = longitudinal.get("stations", [])
if not isinstance(stations, list):
return {}
return {
round(float(station.get("chainage_m", 0))): station
for station in stations
if isinstance(station, dict)
}
def _design_root(project_root: Path) -> Path:
return project_root / _STAGE_DIR
def _read_manifest(project_root: Path) -> dict[str, Any]:
path = _design_root(project_root) / "manifest.json"
if not path.is_file():
return {"drawings": {}}
payload = _read_json(path)
return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}}
def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
stage_root = _design_root(project_root)
stage_root.mkdir(parents=True, exist_ok=True)
path = stage_root / "manifest.json"
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
manifest_drawings = _read_manifest(project_root)["drawings"]
drawings = [
DesignDrawingItem(
id="longitudinal",
kind="longitudinal",
label="종단도 전체",
confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")),
)
]
for path in _cross_files(longitudinal_path, longitudinal):
match = _CROSS_ID.fullmatch(path.stem)
if not match:
continue
chainage = int(match.group(1))
station = station_by_chainage.get(chainage, {})
drawings.append(
DesignDrawingItem(
id=path.stem,
kind="cross",
label=str(station.get("label") or f"STA.{chainage // 1000}+{chainage % 1000:03d}"),
chainage_m=float(station.get("chainage_m", chainage)),
confirmed=bool(manifest_drawings.get(path.stem, {}).get("confirmed")),
)
)
return drawings
def _line_entity(
drawing_id: str,
index: int,
start: tuple[float, float],
end: tuple[float, float],
layer_id: str = _GROUND_LAYER_ID,
color: str = "#f5f7fa",
) -> dict[str, Any]:
entity_id = str(uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{index}"))
return {
"id": entity_id,
"type": "Line",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": {
"startPoint": {"x": start[0], "y": start[1]},
"endPoint": {"x": end[0], "y": end[1]},
},
}
def _text_entity(
drawing_id: str,
index: int,
label: str,
point: tuple[float, float],
) -> dict[str, Any]:
return {
"id": str(
uuid5(
UUID("8ce96e1d-17e8-457b-b46e-329456701225"),
f"{drawing_id}:{index}",
)
),
"type": "Text",
"lineColor": "#cbd5e1",
"lineWidth": 1,
"layerId": "b07-quantity-table",
"shapeData": {
"label": label,
"basePoint": {"x": point[0], "y": point[1]},
"options": {
"textDirection": {"x": 1, "y": 0},
"textAlign": "left",
"textColor": "#cbd5e1",
"fontSize": 0.32,
"fontFamily": "Noto Sans KR",
},
},
}
def _quantity_rows(source: dict[str, Any]) -> list[tuple[str, str, str]]:
ground = source.get("center_z")
planned = source.get("planned_elevation_m", source.get("design_elevation_m"))
cut = (
max(float(ground) - float(planned), 0.0)
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
else None
)
fill = (
max(float(planned) - float(ground), 0.0)
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
else None
)
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
def value(number: Any) -> str:
return f"{float(number):.3f}" if isinstance(number, (int, float)) else "-"
return [
("기본", "측점", str(source.get("label", "-"))),
("기본", "지반고", value(ground)),
("기본", "계획고", value(planned)),
("기본", "절토고", value(cut)),
("기본", "성토고", value(fill)),
("흙깎기", "토사", value(quantities.get("cut_soil"))),
("흙깎기", "연암", value(quantities.get("cut_soft_rock"))),
("흙깎기", "보통암", value(quantities.get("cut_rock"))),
("옆도랑파기", "토사", value(quantities.get("ditch_soil"))),
("옆도랑파기", "연암", value(quantities.get("ditch_soft_rock"))),
("옆도랑파기", "보통암", value(quantities.get("ditch_rock"))),
("비탈보호공", "성토면", value(quantities.get("fill_slope_protection"))),
("비탈보호공", "절토면", value(quantities.get("cut_slope_protection"))),
("기타", "지장목제거", value(quantities.get("tree_removal"))),
("기타", "흙쌓기", value(quantities.get("embankment"))),
("기타", "제근", value(quantities.get("grubbing"))),
("기타", "노면고르기", value(quantities.get("surface_grading"))),
]
def _quantity_table_entities(
source: dict[str, Any], drawing_id: str, points: list[tuple[float, float]]
) -> list[dict[str, Any]]:
if not points:
return []
rows = [("구분", "항목", "값"), *_quantity_rows(source)]
row_height = 0.75
column_widths = (3.2, 3.2, 3.0)
left = max(point[0] for point in points) + 2.0
top = max(point[1] for point in points)
right = left + sum(column_widths)
bottom = top - row_height * len(rows)
entities: list[dict[str, Any]] = []
for row_index in range(len(rows) + 1):
y = top - row_index * row_height
entities.append(
_line_entity(
f"{drawing_id}:table-h",
row_index,
(left, y),
(right, y),
"b07-quantity-table",
"#64748b",
)
)
x_positions = [left]
for width in column_widths:
x_positions.append(x_positions[-1] + width)
for column_index, x in enumerate(x_positions):
entities.append(
_line_entity(
f"{drawing_id}:table-v",
column_index,
(x, top),
(x, bottom),
"b07-quantity-table",
"#64748b",
)
)
text_index = 0
for row_index, row in enumerate(rows):
y = top - row_index * row_height - 0.5
for column_index, label in enumerate(row):
entities.append(
_text_entity(
drawing_id,
text_index,
label,
(x_positions[column_index] + 0.12, y),
)
)
text_index += 1
return entities
def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]:
"""B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다."""
x_key = "chainage_m" if kind == "longitudinal" else "offset_m"
points: list[tuple[float, float]] = []
for sample in source.get("samples", []):
if not isinstance(sample, dict) or not sample.get("valid", False):
continue
x = sample.get(x_key)
y = sample.get("elevation_m", sample.get("z"))
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
children = [
_line_entity(drawing_id, index, points[index], points[index + 1])
for index in range(len(points) - 1)
]
entities: list[dict[str, Any]] = []
if children:
entities.append(
{
"id": str(
uuid5(
UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"),
drawing_id,
)
),
"type": "PolyLine",
"lineColor": "#f5f7fa",
"lineWidth": 1,
"layerId": _GROUND_LAYER_ID,
"shapeData": None,
"children": children,
}
)
if kind == "cross":
entities.extend(_quantity_table_entities(source, drawing_id, points))
return {
"entities": entities,
"layers": [
{
"id": _GROUND_LAYER_ID,
"name": "Existing Ground",
"isVisible": True,
"isLocked": False,
},
{
"id": "b07-quantity-table",
"name": "Quantity Table",
"isVisible": True,
"isLocked": False,
},
],
}
def _read_drawing(
project_root: Path, longitudinal_path: Path, drawing_id: str
) -> tuple[str, str, dict[str, Any], bool]:
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
if manifest_entry.get("confirmed") and saved_path.is_file():
kind = "longitudinal" if drawing_id == "longitudinal" else "cross"
label = str(manifest_entry.get("label") or drawing_id)
return kind, label, _read_json(saved_path), True
if drawing_id == "longitudinal":
source = _read_json(longitudinal_path)
return (
"longitudinal",
"종단도 전체",
_cad_drawing(source, drawing_id, "longitudinal"),
False,
)
if not _CROSS_ID.fullmatch(drawing_id):
raise ValueError("올바르지 않은 도면 ID입니다.")
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
if not path.is_file():
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
label = str(source.get("label") or drawing_id)
return "cross", label, _cad_drawing(source, drawing_id, "cross"), False
def _store_confirmed_drawing(
project_root: Path,
item: DesignDrawingItem,
drawing: dict[str, Any],
expected_ids: set[str],
) -> bool:
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
drawings_dir = _design_root(project_root) / "drawings"
drawings_dir.mkdir(parents=True, exist_ok=True)
path = drawings_dir / f"{item.id}.json"
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
manifest = _read_manifest(project_root)
manifest["drawings"][item.id] = {
"kind": item.kind,
"label": item.label,
"confirmed": True,
"file": f"drawings/{item.id}.json",
}
_write_manifest(project_root, manifest)
confirmed_ids = {
item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed")
}
return expected_ids.issubset(confirmed_ids)
def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
manifest = _read_manifest(project_root)
entry = manifest["drawings"].get(drawing_id)
if entry:
entry["confirmed"] = False
_write_manifest(project_root, manifest)
@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)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
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)
kind, label, drawing, confirmed = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id
)
return DesignDrawingResponse(
project_id=str(project_id),
route_id=route_id,
id=drawing_id,
kind=kind,
label=label,
drawing=drawing,
confirmed=confirmed,
)
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:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다."""
try:
_, project_root, longitudinal_path = await _confirmed_source(project_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
if not item:
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
all_confirmed = await asyncio.to_thread(
_store_confirmed_drawing,
project_root,
item,
request.drawing,
{candidate.id for candidate in items},
)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
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,
)
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:
_, 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)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
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": "상세 설계 도면 상태를 되돌리지 못했습니다."},
)