"""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 B05_Profile.B05_Profile_Structures_Schema import structure_type_map 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 check_against_plan from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_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 ( CONCRETE_PLACING_METHODS, ROCK_METHODS, application_ratio, concrete_placing_method, 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 from config.config_system_design import EARTHWORK_CONVERSION_FACTORS 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) # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. table["pipe_lengths"] = [ { "chainage_m": row.get("chainage_m"), "pipe_length_m": (row.get("design") or {}).get("pipe_length_m"), } for row in designs if isinstance(row, dict) and (row.get("design") or {}).get("pipe_length_m") is not None ] # 횡단이 선 측점 목록 — 관 줄이 「길이가 없음」과 「횡단 자체가 없음」을 가르는 데 쓴다. table["section_chainages"] = [row.get("chainage_m") for row in designs if isinstance(row, dict)] table["haul"] = haul # 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다. table["haul_available"] = bool(plan) # ⚠ 검산을 **실제로 부른다** — 무대·도자·덤프 합이 운반계획 총량과 맞는가(8-7 ㉡). # 2026-09-08 ㉘ 자기 감사: 만들어 두고 시험에서만 부르고 있었다. 값을 막지는 않고 # 차이만 실어 화면이 띄우게 한다 — 막으면 계획이 없는 정상 상태에서도 멈춘다. if plan: check = check_against_plan(haul, plan) table["haul_check"] = { "hauled_total_m3": check.hauled_total_m3, "plan_total_m3": check.plan_total_m3, "difference_m3": check.difference_m3, "by_equipment": check.details, } 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 {}) }, ) ) # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). structures = await _route_structures(project_id) table["preparation"] = build_preparation_table( slope.get("totals") or {}, structures, slope.get("rows") or [], settings.get("topsoil_thickness_m"), {type_id: definition.name for type_id, definition in structure_type_map().items()}, # 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬). settings.get("ancillary_counts") or {}, # 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값). settings.get("topsoil_haul_distance_m"), # 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다). settings.get("stand_volume_class"), # 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번). settings.get("wood_chipping_enabled"), settings.get("wood_chipping_volume_m3"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. table["concrete_placing"] = { "method": method, "is_default": method_is_default, # ⚠ **표시 전용 참고값** — 「무엇을 정해야 하는지」만으로는 부족하고 # 「정하면 얼마나 달라지는지」가 보여야 사용자가 판단한다(2026-09-07 조율 창). # B08 의 어떤 계산에도 안 들어간다. "price_hint": (load_mapping().concrete_placing or {}).get("price_hint_krw_per_m3"), } table["settings"] = settings table["project_root_known"] = project_root is not None table["route_id"] = route_id return JSONResponse(content=table) #: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다. _COMPACTED_FACTOR = { "ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]), "rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]), "br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]), } def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]: """배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다. ⚠ 여기서 **다시 세지 않는다** — 용량·담긴 양은 B06 이 정한 값이고, 이 함수는 그것을 구조물 단위로 접어 「어디에 얼마나 담기나」만 만든다. """ sites: dict[str, dict[str, Any]] = {} for row in designs: design = (row or {}).get("design") or {} key = str(design.get("spoil_fill_structure_id") or "") if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0: continue chainage = float(row.get("chainage_m") or 0.0) site = sites.setdefault( key, { "structure_id": key, "from_m": chainage, "to_m": chainage, "capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0), "placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0), "unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0), "extra_distance_m": design.get("spoil_fill_extra_distance_m"), }, ) site["from_m"] = min(site["from_m"], chainage) site["to_m"] = max(site["to_m"], chainage) for site in sites.values(): site["center_m"] = (site["from_m"] + site["to_m"]) / 2 return sorted(sites.values(), key=lambda item: item["center_m"]) def _site_distance_m( sites: list[dict[str, Any]], residuals: list[dict[str, Any]] ) -> tuple[float | None, str]: """사토장까지의 **가중평균 운반거리**(m)와 근거 문구. 「발생점 → 사토장 측점」 누가거리다(2026-09-09 사용자 확정 ③ — 사토장이 측점 위에만 서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다. ⚠ 사토장이 없으면 `None` — 설계 입력(`spoil_site_distance_m`)으로 되돌아간다. **임의 거리를 넣지 않는다**(그대로 금액이 된다). """ if not sites: return None, "" work = 0.0 volume = 0.0 for residual in residuals: if str(residual.get("kind") or "") != "spoil": continue amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0) if amount <= 0: continue center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2 nearest = min(sites, key=lambda site: abs(site["center_m"] - center)) extra = nearest.get("extra_distance_m") distance = abs(nearest["center_m"] - center) + float(extra or 0.0) work += amount * distance volume += amount if volume <= 0: return None, "" where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites) return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균" def _spoil_of( plan: dict[str, Any] | None, settings: dict[str, Any], sites: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다. ⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤). 여기서 또 만지면 두 번 셈이 된다. ⚠ 자연방토(`natural_spoil_m3`)는 실어 내지 않는 몫이라 **뺀다**. ⚠⚠ **상태가 갈린다.** 유토곡선 잔량은 **다짐상태**이고 품셈 운반(10-11·10-12)의 밑수는 **자연상태**다(식이 `f = 1/L` 을 스스로 곱한다). 잔량이 되돌린 값 (`natural_m3_by_ground`)을 들고 오면 **그것을 쓰고**, 없으면 다짐값으로 서되 그 사실을 근거에 적는다 — 조용히 쓰면 상태가 어긋난 물량이 단가에 물린다(2026-09-09 두 창 확인). """ haul_plan = (plan or {}).get("haul_plan") if isinstance(plan, dict) else None source = haul_plan if isinstance(haul_plan, dict) else (plan or {}) total = float(source.get("spoil_m3") or 0.0) natural = float(source.get("natural_spoil_m3") or 0.0) volume = max(total - natural, 0.0) # 지반 갈래 — 사토 잔량이 갈래별 물량을 들고 온다(2026-09-08 랩탑 메인). 갈래를 못 붙인 # 몫은 `ground_unknown_m3` 로 따로 온다. **여기서 안분하지 않는다** — 근거 없는 몫을 # 토사로 눅이면 덤프 단가가 임의로 정해진다. grounds: dict[str, float] = {} unknown = 0.0 # 잔량마다 **사토장까지 거리**가 실려 올 수 있다(2026-09-09 사용자 확정 — 사토장은 이미 # 있는 측점 위에만 놓이므로 「발생점 → 사토장 측점」 누가거리로 그냥 나온다). # 갈래별 **가중평균**을 낸다 — 실무 내역이 (운반수단 × 지반)별 평균 하나를 올린다. work: dict[str, float] = {} metered: dict[str, float] = {} for residual in source.get("residuals") or []: if str(residual.get("kind") or "") != "spoil": continue leg_distance = residual.get("spoil_haul_distance_m") # ⚠ 잔량은 **다짐상태**로만 읽는다 — 되돌리는 자리는 아래 한 곳뿐이다. # 두 곳에서 되돌리면 ÷C 가 두 번 걸린다. for key in ("ea_m3", "rr_m3", "br_m3"): value = float(residual.get(key) or 0.0) if value > 0: grounds[key] = grounds.get(key, 0.0) + value if isinstance(leg_distance, (int, float)) and float(leg_distance) > 0: work[key] = work.get(key, 0.0) + value * float(leg_distance) metered[key] = metered.get(key, 0.0) + value unknown += float(residual.get("ground_unknown_m3") or 0.0) note_parts = [f"사토 {total:,.2f}㎥"] if natural > 0: note_parts.append(f"자연방토 {natural:,.2f}㎥ 뺀 값") added = source.get("structure_spoil_added_m3") if added: note_parts.append(f"구조물 잔토 {float(added):,.2f}㎥ 얹힌 뒤") deducted = source.get("collected_stone_deducted_m3") if deducted: note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤") # ⚠ **상태를 값으로 낸다**(2026-09-09) — 잔량은 유토곡선이 쌓은 **다짐상태**이고, # 내역서에 오르는 수량은 **자연상태**다(`config_system_design` 5-4-3 「운반거리 산정 시 # 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 수량은 자연상태로 한다**」). # 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다. # ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이 # 금액이 움직인다. 그 사실을 사유로 낸다. natural_by_ground = { key: round(value / _COMPACTED_FACTOR[key], 3) for key, value in grounds.items() if key in _COMPACTED_FACTOR } if unknown > 0: note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림") # 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값. site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or []) if site_distance is not None: note_parts.append(site_basis) placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or []) unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or []) note_parts.append(f"사토장 수용 {placed:,.1f}㎥") if unplaced > 0: note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함") return { "volume_m3": round(volume, 3), "volume_basis": "compacted", "distance_m": ( round(site_distance, 3) if site_distance is not None else settings.get("spoil_site_distance_m") ), "distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"), "sites": sites or [], "note": " · ".join(note_parts), "by_ground_m3": {key: round(value, 3) for key, value in grounds.items()}, "natural_m3_by_ground": natural_by_ground, "natural_volume_basis": "natural", "ground_unknown_m3": round(unknown, 3), # 갈래별 가중평균 거리 — 사토장이 놓였을 때만 찬다. 비면 설정 거리로 떨어진다. "distance_by_ground_m": { key: round(work[key] / metered[key], 2) for key in work if metered.get(key) }, } async def _route_structures(project_id: UUID) -> list[dict[str, Any]]: """배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록.""" try: stored_path = await run_with_connection(get_project_storage_relative_path, project_id) root = resolve_stored_project_path(stored_path) from B05_Profile.B05_Profile_Structures_Repository import load_structures _revision, items = load_structures(root) return [item.model_dump() for item in items] except Exception: logger.warning("B08 준비공 — 구조물 목록을 못 읽음: project_id=%s", project_id) return [] 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: """정본에 남은 **배분**(`mass_haul.haul_plan`). [확정]을 아직 안 돌렸으면 없다. ⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다. 바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다 — [확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다(2026-09-07 실증에서 잡음). """ 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 {} mass_haul = data.get("mass_haul") if isinstance(data, dict) else None if not isinstance(mass_haul, dict): return None plan = mass_haul.get("haul_plan") 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 # 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다. rock_methods: dict[str, str] | None = None application_ratios_pct: dict[str, float] | None = None # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). material_supply: dict[str, Any] | None = None # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. concrete_placing_method: str | None = None # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). topsoil_thickness_m: float | None = None # 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬). # ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고 # 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다. ancillary_counts: dict[str, float] | None = None # 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①). bench_cut_depth_m: float | None = None # 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다. spoil_site_distance_m: float | None = None # 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). rubble_base_thickness_m: float | None = None # 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: str | None = None # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. topsoil_haul_distance_m: float | None = None # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. stand_volume_class: str | None = None # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. frame_material: dict[str, Any] | None = None # 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다. wood_chipping_enabled: bool | None = None wood_chipping_volume_m3: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. #: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. NULLABLE_SETTING_KEYS = ( "topsoil_thickness_m", "bench_cut_depth_m", "spoil_site_distance_m", "rubble_base_thickness_m", "topsoil_haul_distance_m", "wood_chipping_volume_m3", ) @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} # ⚠ `None` 을 통째로 버리면 **「안 정함」으로 되돌릴 길이 없다** — 한 번 넣은 값이 # 영영 남는다(2026-09-08 ㉘ 자기 감사). 시공법·타설 방식은 빈 문자열로 되돌리지만 # 숫자 칸은 되돌리는 값이 `None` 뿐이라, **화면이 보낸 것**만 골라 살린다. for key in NULLABLE_SETTING_KEYS: if key in body.model_fields_set: values[key] = getattr(body, key) if "concrete_placing_method" in values: method = values["concrete_placing_method"] # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None if "rock_methods" in values: # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 # 여기서 버리면 그 갈래는 미지정으로 돌아간다. values["rock_methods"] = { name: method for name, method in values["rock_methods"].items() if method in ROCK_METHODS } try: # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). saved = await asyncio.to_thread( _save_quantity, root, values, ("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts") + NULLABLE_SETTING_KEYS, ) 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 {}}) def _save_quantity( root: str, values: dict[str, Any], replace_keys: tuple[str, ...] ) -> dict[str, Any]: return save_section(root, "quantity", values, replace_keys=replace_keys) @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"]))