Files
Aislo/B08_Quantity/B08_Quantity_Router_Earthwork.py
T
eomsangdonandClaude Opus 5 01578eaed6 feat(B08): 타설 방식 단가 차이 표시 + 값 자리 식 깃발
타설 방식 — 「무엇을 정해야 하는지」만으로는 부족하고 「정하면 얼마나 달라지는지」가
보여야 사용자가 판단함. 세 방식 단가 차이가 6배라 조용히 기본값을 쓰면 총액이 갈림.
- 방식 이름을 늘 값 옆에 보임(「적용 중: 레디믹스트 (기본값 — 확인 필요)」).
  코드(12-01-01)만으로는 무엇을 쓰는지 모름.
- 참고 단가 세 줄을 안내로 띄움 — 레디믹스트 65,826 · 기계비빔 163,508 ·
  인력비빔 408,327 원/㎥. ⚠ **표시 전용**이며 B08 의 어떤 계산에도 안 들어감
  (금액은 B09 몫). 출처·산출일을 데이터에 함께 적음.

품셈 마스터 — 값 자리에 식이 적힌 칸에 깃발(`expression_cells`).
`0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적힌 칸은 값이 숫자로 안 읽혀
그 성분이 조용히 빠짐. 형태 판정은 통과하고 배분율 딱지도 없어 아무 검사에도
안 걸리던 자리(서브 창이 실물에서 부딪힘 — 기초잡석이 부설다짐 0.6인만으로 섬).
⚠ 식을 계산하지 않고 드러내기만 함 — 뜻을 잘못 읽으면 조용히 틀림.
31표에서 잡힘(9-12·9-13 터파기 계열 · 12-24 뒷채움 · 12-25 기초잡석 등).

검증 — 품셈 34건 통과, 전체 588 passed, tsc 오류 0.
화면에서 방식 이름·기본값 표시·단가 세 줄 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:21:44 +09:00

247 lines
12 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_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
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["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id)
)
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)
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
@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}
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"),
)
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"]))