- 자재 = 다짐 후 부피 ÷ C × L · 두께 제안 0.10(교본) · C 0.85 · L 1.25 제안(소광 관측 · 원문 표에 혼합석 줄 없음 · 역 C 와 방향 반대 사유) - 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(local_grade_pct 로 뺌 — 종단 파일 측점 경사엔 비정규 측점이 없었음) - 해당 측점이 없으면 0 원 줄 안 세움 · 다짐 줄은 공종 없어 사유 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
599 lines
31 KiB
Python
599 lines
31 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`, 그리고
|
|
측구 가름값 `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"]))
|