"""B08 토적표 조회 라우터 (일감 2 · PLAN 8-4b). 값은 어디서 오나 측점별 단면적은 **B06 이 이미 낸 정본**이다(`cross_sections.data.design` 의 `cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`, 그리고 측구 가름값 `ditch_soil_area_m2`·`ditch_rock_area_m2`·`ditch_split_basis`). 설계 dict 를 통째로 `StationArea.from_design` 에 넘기므로 키가 늘어도 여기는 안 고친다. B08 은 그것을 다시 재지 않고 **평균단면적법으로 체적화만** 한다. 계산 자리 (CLAUDE.md 5장) 초기값은 서버가 한 번 계산해 영구저장한다. 여기서는 저장된 단면적을 읽어 표를 만든다 — 새 수량을 낳지 않으므로 캐시·조작 경로가 따로 필요 없다. """ from __future__ import annotations import asyncio import json import logging 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 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 ( FACE_DRESSING_CUT_CLASSES, FACE_DRESSING_FILL_CLASSES, FACE_DRESSING_FILL_SUGGESTED, ROOT_REMOVAL_EXCAVATOR_SIZES, ROOT_REMOVAL_EXCAVATOR_SUGGESTED, SEED_SPRAY_GROUNDS, 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_GravelSurfacing import gravel_surfacing from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( borrow_of, check_against_plan, summary_input_rows, ) from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import ( # noqa: F401 — 다시 내보냄 NULLABLE_SETTING_KEYS, QuantitySettingsBody, clean_setting_values, ) from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import ( recompute_haul_plan as _recompute_haul_plan, ) from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from common_util.common_util_project_settings import ( application_ratio, concrete_placing_method, earthwork_conversion_choices, mixed_conversion_factors, haul_limit_choice, quantity_settings, rock_classes, rock_method, save_section, topsoil_target, ) 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_PUMSEM_C_RANGES, ) 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": "토적표를 만들지 못했습니다."}, ) # ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다. settings, project_root = await _project_settings(project_id) # 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수. factors = mixed_conversion_factors(settings) table = build_table(_stations(designs), factors) # 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다. table["conversion_factor_choices"] = earthwork_conversion_choices(settings) # 도쟈 한계거리 — 지금 값·기본값·근거를 함께 보인다(유토곡선 장비 경계, 2026-09-13 판정). table["haul_limit_choice"] = haul_limit_choice(settings) # 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다 # (프론트에 다시 적으면 두 벌이 되어 갈린다). table["conversion_factor_pumsem_ranges"] = [ {"name": name, "min": low, "max": high} for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES ] # 사면 계열은 저장된 설계선에서 유도한다. slopes = station_slopes(designs) slope = build_slope_table(slopes) table["slope"] = slope plan = await _stored_haul_plan(project_id, route_id) haul = build_haul_table(plan, factors) # ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인). classes = rock_classes(settings) methods = {name: rock_method(settings, name) for name in classes} apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods) # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①) # 배수관 연장 — 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, } # 혼합석 부설 — 법령 조건 자동(종단 8% = B05 측점 경사 · 토질 = 지반 프리셋) · 칸(2026-09-15). gravel = gravel_surfacing( designs, await _station_grades(project_id, route_id, project_root, designs), settings ) table["gravel"] = gravel table["summary"] = build_summary_table( SummaryInput( gravel=gravel, earthwork_totals=table.get("totals") or {}, slope_totals=slope.get("totals") or {}, haul_rows=summary_input_rows(haul), borrow_m3=(haul["borrow"] or {}).get("volume_m3") or 0.0, borrow_sites=(haul["borrow"] or {}).get("sites") or [], 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 {}) }, # 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(2026-09-13 판정). subgrade_compaction_enabled=bool(settings.get("subgrade_compaction_enabled")), # 면고르기 면적 덮어쓰기 — 비우면 파종 면적(2026-09-14 판정 Ⓐ). face_dressing_area_m2={ "fill": settings.get("face_dressing_fill_area_m2"), "cut": settings.get("face_dressing_cut_area_m2"), }, ) ) # 면고르기 갈래 고르기 — 선택지는 서버 한 곳(원문 표 두 벌) · 제안값 없음(판정 Ⓒ). table["face_dressing_choices"] = { "cut": list(FACE_DRESSING_CUT_CLASSES), "fill": list(FACE_DRESSING_FILL_CLASSES), # 성토면 제안(회색 · [제안값 넣기]) — 비워 두면 여전히 「안 정함」(브레인 ②). "fill_suggested": { "value": FACE_DRESSING_FILL_SUGGESTED[0], "basis": FACE_DRESSING_FILL_SUGGESTED[1], }, } # 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인). table["root_removal_excavator_choices"] = { "choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES), "suggested": { "value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0], "basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1], }, } # 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮). table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)} # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). 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"), # 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」). road_surface_area(slopes), # 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정). topsoil_target(settings) == "road_only", # 임목폐기물 — 현장 조사값 · 수동 처리단가 · 분리발주(2026-09-14 판정). settings.get("tree_waste") or {}, settings.get("tree_waste_unit_price_krw_per_ton"), settings.get("waste_separate_order"), settings.get("tree_waste_root_method"), ) 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 # 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라 # 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다. provenance = quantity_provenance() if provenance is not None: table["provenance"] = provenance return JSONResponse(content=table) #: 갈래 칸 ↔ 지반유형 이름. 계수의 정의처는 `config_system_design` 한 곳뿐이다. _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_rock"} def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]: """갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다.""" factors = mixed_conversion_factors(settings) return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()} 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 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다. # ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이 # 금액이 움직인다. 그 사실을 사유로 낸다. compacted_factor = _compacted_factor(settings) 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 async def _station_grades( project_id: UUID, route_id: int, project_root: str | None, designs: list[dict[str, Any]] ) -> dict[float, float]: """횡단 측점마다 종단 경사(%) — 종단 파일 계획선에서 B05 포장 제안과 **같은 식**으로. ⚠ 종단 파일 `stations` 의 `pavement_grade_pct` 는 정규 측점뿐(비정규 측점 85.052 등이 빠짐) — 계획선에서 바로 셈. 못 읽으면 빈 표(혼합석 판정이 「경사 못 읽음」 사유). """ from B05_Profile.B05_Profile_Engine_Sections import local_grade_pct try: row = await run_with_connection(get_longitudinal_section, project_id, route_id) path = Path(str(project_root)) / str((row or {})["longitudinal_file_path"]) profiles = json.loads(path.read_text(encoding="utf-8")).get("design_profiles") or [] points = [ (float(s["chainage_m"]), float(s["elevation_m"])) for s in (profiles[0].get("samples") or [] if profiles else []) if isinstance(s.get("chainage_m"), (int, float)) and isinstance(s.get("elevation_m"), (int, float)) ] except Exception: logger.warning("B08 혼합석 — 종단 계획선을 못 읽음: route_id=%s", route_id) return {} if len(points) < 2: return {} return { round(float(item["chainage_m"]), 3): local_grade_pct(points, float(item["chainage_m"])) for item in designs if isinstance(item, dict) and isinstance(item.get("chainage_m"), (int, float)) } @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) error = clean_setting_values(values) if error: # 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다. return JSONResponse(status_code=400, content={"status": "error", "message": error}) before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS} try: # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). saved = await asyncio.to_thread( _save_quantity, root, values, ( "rock_methods", "material_supply", "material_surcharge", "concrete_placing_method", "topsoil_target", "tree_waste", "tree_waste_root_method", "ancillary_counts", # 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다. "conversion_factors_override", "gravel_soft_wet_ranges", ) + NULLABLE_SETTING_KEYS, ) except Exception: logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, ) after = quantity_settings(root) changed = any(before[key] != after.get(key) for key in HAUL_PLAN_KEYS) recomputed = await _recompute_haul_plan(project_id) if changed else False return JSONResponse( content={ "status": "success", "quantity": saved.get("quantity") or {}, "haul_plan_recomputed": recomputed, } ) 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"]))