일감 4·5 의 API 덩어리. 한 응답에 실음 earthwork-table 이 토적표(체적) · slope(사면 4계열) · haul(운반 가중평균) · summary(토공집계표) · settings 를 함께 냄. 실무 산출서가 한 벌로 움직이는 값이라 나눠 부르면 같은 측점 목록을 여러 벌 들게 됨. 운반계획이 없으면 빈 표가 정직함 운반계획은 [저장]·[확정]에서 정본에 남는 값임(longitudinal.data.mass_haul). 아직 안 돌린 프로젝트는 없음 — haul_available 로 알려 화면이 「확정하면 생김」을 말할 수 있게 함. 지어내지 않음. 산출 조건 저장 — PUT /quantity/settings ⚠ 자동저장이 아님(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어감. ⚠ quantity 구획만 씀 — estimation 은 B09 것이고 모듈이 구조로 막고 있음. 보내지 않은 칸은 저장분을 그대로 둠. 프로젝트 경로를 못 찾아도 조회는 기본값으로 서게 함 — 설정은 계산을 거드는 값이지 없으면 못 도는 값이 아님. 암 비율 안분을 비고에 드러냄 (조율 창 지적) 설계자가 60/30 을 넣으면 총량 보존을 위해 66.7/33.3 으로 안분됨. 값이 말없이 바뀌는 것이라 「입력 합 90 % → 100 % 로 안분」을 비고에 남김. 합이 100 이면 비고를 비움 — 제대로 넣었는데 안내가 뜨면 잡음이 됨. 검증 — 회귀 433 passed (실패 1건은 B05 코리도 기존 깨짐). 엔드포인트 3종 등록 확인(earthwork-table 2 + settings 1). 안분 비고는 시험으로 못 박음(합 90 이면 비고 있음 · 100 이면 없음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
7.7 KiB
Python
174 lines
7.7 KiB
Python
"""B08 토적표 조회 라우터 (일감 2 · PLAN 8-4b).
|
|
|
|
값은 어디서 오나
|
|
측점별 단면적은 **B06 이 이미 낸 정본**이다(`cross_sections.data.design` 의
|
|
`cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`).
|
|
B08 은 그것을 다시 재지 않고 **평균단면적법으로 체적화만** 한다.
|
|
|
|
계산 자리 (CLAUDE.md 5장)
|
|
초기값은 서버가 한 번 계산해 영구저장한다. 여기서는 저장된 단면적을 읽어 표를 만든다 —
|
|
새 수량을 낳지 않으므로 캐시·조작 경로가 따로 필요 없다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B06_Section.B06_Section_Repository import (
|
|
get_cross_section_designs,
|
|
get_longitudinal_section,
|
|
get_workflow_route_context,
|
|
)
|
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
|
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
|
|
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
|
|
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
|
|
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
|
|
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
|
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
|
|
from common_util.common_util_project_settings import (
|
|
application_ratio,
|
|
quantity_settings,
|
|
rock_classes,
|
|
save_section,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import run_with_connection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
|
|
|
|
|
def _stations(designs: list[dict[str, Any]]) -> list[StationArea]:
|
|
return [
|
|
StationArea.from_design(item["chainage_m"], item.get("design") or {}) for item in designs
|
|
]
|
|
|
|
|
|
@router.get("/{project_id}/quantity/{route_id}/earthwork-table")
|
|
async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|
"""토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다.
|
|
|
|
실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을
|
|
두 벌로 들게 된다.
|
|
"""
|
|
try:
|
|
designs = await run_with_connection(get_cross_section_designs, route_id)
|
|
except Exception:
|
|
logger.exception("B08 토적표 조회 실패: project_id=%s route_id=%s", project_id, route_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
|
|
)
|
|
table = build_table(_stations(designs))
|
|
# 사면 계열은 저장된 설계선에서 유도한다.
|
|
slope = build_slope_table(station_slopes(designs))
|
|
table["slope"] = slope
|
|
|
|
settings, project_root = await _project_settings(project_id)
|
|
plan = await _stored_haul_plan(project_id, route_id)
|
|
haul = build_haul_table(plan)
|
|
table["haul"] = haul
|
|
# 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다.
|
|
table["haul_available"] = bool(plan)
|
|
|
|
table["summary"] = build_summary_table(
|
|
SummaryInput(
|
|
earthwork_totals=table.get("totals") or {},
|
|
slope_totals=slope.get("totals") or {},
|
|
haul_rows=summary_input_rows(haul),
|
|
rock_classes=rock_classes(settings),
|
|
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
|
|
application_ratios={
|
|
key: application_ratio(settings, key)
|
|
for key in (settings.get("application_ratios_pct") or {})
|
|
},
|
|
)
|
|
)
|
|
table["settings"] = settings
|
|
table["project_root_known"] = project_root is not None
|
|
table["route_id"] = route_id
|
|
return JSONResponse(content=table)
|
|
|
|
|
|
async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]:
|
|
"""프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다."""
|
|
try:
|
|
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
|
root = resolve_stored_project_path(stored_path)
|
|
except Exception:
|
|
logger.warning("B08 프로젝트 경로를 못 찾음: project_id=%s", project_id)
|
|
from common_util.common_util_project_settings import default_settings
|
|
|
|
return default_settings()["quantity"], None
|
|
return quantity_settings(root), root
|
|
|
|
|
|
async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | None:
|
|
"""정본에 남은 운반계획. [확정]을 아직 안 돌렸으면 없다."""
|
|
try:
|
|
row = await run_with_connection(get_longitudinal_section, project_id, route_id)
|
|
except Exception:
|
|
logger.exception("B08 운반계획 조회 실패: route_id=%s", route_id)
|
|
return None
|
|
data = (row or {}).get("data") or {}
|
|
plan = data.get("mass_haul") if isinstance(data, dict) else None
|
|
return plan if isinstance(plan, dict) and plan else None
|
|
|
|
|
|
class QuantitySettingsBody(BaseModel):
|
|
"""[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다."""
|
|
|
|
rock_class_set: str | None = None
|
|
rock_classes: list[str] | None = None
|
|
rock_ratios_pct: dict[str, float] | None = None
|
|
application_ratios_pct: dict[str, float] | None = None
|
|
|
|
|
|
@router.put("/{project_id}/quantity/settings")
|
|
async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse:
|
|
"""산출 조건을 정본에 남긴다 — [저장]이 부르는 자리.
|
|
|
|
⚠ 자동저장이 아니다(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어간다.
|
|
⚠ `quantity` 구획만 쓴다 — `estimation` 은 B09 것이라 손대지 않는다(모듈이 막고 있다).
|
|
"""
|
|
try:
|
|
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
|
root = resolve_stored_project_path(stored_path)
|
|
except Exception:
|
|
logger.exception("B08 설정 저장 실패(경로): project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
|
)
|
|
values = {key: value for key, value in body.model_dump().items() if value is not None}
|
|
try:
|
|
saved = await asyncio.to_thread(save_section, root, "quantity", values)
|
|
except Exception:
|
|
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
|
|
)
|
|
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
|
|
|
|
|
|
@router.get("/{project_id}/quantity/earthwork-table")
|
|
async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse:
|
|
"""경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다."""
|
|
context = await run_with_connection(get_workflow_route_context, project_id)
|
|
if not context or not context.get("route_id"):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "이 프로젝트에 확정된 노선이 없습니다."},
|
|
)
|
|
return await get_earthwork_table(project_id, int(context["route_id"]))
|