fix(B06): 구조물 몫(공제·잔토)이 실제로 흐르게 — 부르는 자리 둘이 인자를 안 넘기고 있었음

통로는 났는데 `_mass_haul_context()` 를 **인자 없이** 불러 값이 늘 `None` 이었다
(`Router_HaulPlan.py:64` · `Server_Calc_Prebuild.py:170`). 오늘 세 번째로 나온
「받는 곳은 있는데 넣는 곳이 없던」 자리다.

- `haul_inputs_for()` 로 B08 의 `project_haul_inputs` 를 받아 두 자리 모두에 넘긴다.
  ⚠ 여기서 다시 세지 않는다 — 두 값 다 B08 전개에서 나온다(CLAUDE.md 5장).
- 측점별 잔토(`structure_spoil_points`)를 받으면 **그 자리 잔량에 얹는다** —
  총량 비례로 흩으면 운반거리가 틀린다. 총량만 오면 종전대로 비례 분배(차선).
- 저장 payload 에 받은 값·실제로 먹은 값 넷을 남긴다 — 「값이 흐르는지」를 저장분에서
  바로 가릴 수 있어야 한다.

실측(route 184): 공제 0(구입이라 채집 없음) · 구조물 잔토 12.875㎥ 가 **실제로 전달됨**.
⚠ 그 노선은 사토가 0(전 구간 토취)이라 더할 잔량이 없어 `added=0` — 아래 물음 참조.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:44:50 +09:00
co-authored by Claude Opus 5
parent 428fe7aeed
commit abf586108c
4 changed files with 96 additions and 9 deletions
+16 -2
View File
@@ -22,7 +22,11 @@ from uuid import UUID
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
haul_inputs_for,
)
from common_util.common_util_node_bundle import run_bundle_json
logger = logging.getLogger(__name__)
@@ -56,12 +60,22 @@ async def compute_haul_plan(
status_code=400,
content={"status": "error", "message": "측점 수가 너무 많습니다."},
)
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"haul_plan_for": result, "context": _mass_haul_context()},
{
"haul_plan_for": result,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
},
)
except Exception:
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
@@ -63,9 +63,26 @@ _AREA_KEYS = (
)
async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
"""B08 이 낸 **구조물 몫**(채집석 공제·구조물 잔토)을 받아 온다.
⚠ **여기서 다시 세지 않는다** — 두 값 다 B08 전개에서 나오는 것이라 이쪽이 세면
같은 계산이 두 벌이 된다(CLAUDE.md 5장). 못 읽으면 빈 값으로 두고 **0 으로 눅이지 않는다**.
"""
try:
from B08_Quantity.B08_Quantity_Router_Material import project_haul_inputs
data = await project_haul_inputs(project_id)
return data if isinstance(data, dict) else {}
except Exception:
logger.exception("구조물 몫(공제·잔토) 조회 실패 — 값 없이 진행: project_id=%s", project_id)
return {}
def _mass_haul_context(
collected_stone_deduction_m3: float | None = None,
structure_spoil_m3: float | None = None,
structure_spoil_points: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
@@ -93,6 +110,8 @@ def _mass_haul_context(
# B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
# 더하는 자리는 유토곡선의 사토뿐이다.
"structure_spoil_m3": structure_spoil_m3,
# 측점별 잔토 — 오면 이쪽이 이긴다(구조물이 선 자리 잔량에 얹어 운반거리를 맞춘다).
"structure_spoil_points": structure_spoil_points,
}
@@ -134,10 +153,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
# 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다.
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측).
pool = get_db_pool()
response, stored_path, longitudinal_row = await asyncio.gather(
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
@@ -167,7 +189,14 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
{
"detail": detail,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
if not isinstance(output, dict):
+42 -5
View File
@@ -391,14 +391,44 @@ function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: numb
*
* 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다.
*/
function applyStructureSpoil(residuals: HaulResidual[], amount: number | null): number {
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
function applyStructureSpoil(
residuals: HaulResidual[],
amount: number | null,
points: Array<{ chainage_m: number; spoil_m3: number }> | null,
): number {
const spoils = residuals.filter((residual) => residual.kind === "spoil");
if (!spoils.length) return 0;
// ① 측점별 값이 오면 **그 자리 잔량**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
if (points && points.length) {
let added = 0;
for (const point of points) {
const value = Number(point?.spoil_m3);
const chainage = Number(point?.chainage_m);
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue;
// 그 측점을 품은 잔량 → 없으면 가장 가까운 잔량. 버리지 않는다.
const covering = spoils.find(
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
);
const target =
covering ??
spoils.reduce((best, residual) => {
const middle = (residual.from_m + residual.to_m) / 2;
const bestMiddle = (best.from_m + best.to_m) / 2;
return Math.abs(chainage - middle) < Math.abs(chainage - bestMiddle) ? residual : best;
}, spoils[0]);
target.volume_m3 += value;
added += value;
}
return added;
}
// ② 총량만 오면 **남은 사토 잔량 크기에 비례**해 나눈다(어느 자리인지 모를 때의 차선).
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
if (!spoils.length || total <= EPSILON) return 0;
if (total <= EPSILON) return 0;
let added = 0;
spoils.forEach((residual, index) => {
// 마지막 잔량은 나머지를 그대로 받아 반올림 오차가 새지 않게 한다.
const share =
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
residual.volume_m3 += share;
@@ -413,6 +443,8 @@ export function computeHaulPlan(
options?: {
collected_stone_deduction_m3?: number | null;
structure_spoil_m3?: number | null;
/** 측점별 구조물 잔토 — 오면 **이쪽이 이긴다**(그 자리 잔량에 얹어 운반거리를 맞춘다). */
structure_spoil_points?: Array<{ chainage_m: number; spoil_m3: number }> | null;
},
): HaulPlan | null {
const points = result.points;
@@ -595,7 +627,12 @@ export function computeHaulPlan(
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
const structureSpoilInput = options?.structure_spoil_m3 ?? null;
const structureSpoilAdded = applyStructureSpoil(settled, structureSpoilInput);
const structureSpoilPoints = options?.structure_spoil_points ?? null;
const structureSpoilAdded = applyStructureSpoil(
settled,
structureSpoilInput,
structureSpoilPoints,
);
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
remaining.forEach((residual, index) => {
residual.index = index + 1;
@@ -179,6 +179,13 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 구조물 몫 — **받은 값**과 **실제로 먹은 값**을 함께 남긴다. 「통로만 있고 값이 안 흐른다」를
// 저장분에서 바로 가릴 수 있어야 한다(2026-09-09 그 사고가 세 번 났다).
// `null` 은 「아직 안 옴」, `0` 은 「없음」이다 — 눅이지 않는다.
collected_stone_deduction_m3: plan.collected_stone_deduction_m3,
collected_stone_deducted_m3: round(plan.collected_stone_deducted_m3),
structure_spoil_m3: plan.structure_spoil_m3,
structure_spoil_added_m3: round(plan.structure_spoil_added_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,