From abf586108c4e8c27ddf840906560c35fc786f594 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 22:44:50 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=AA=AB(=EA=B3=B5=EC=A0=9C=C2=B7=EC=9E=94=ED=86=A0)=EC=9D=B4?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=9D=90=EB=A5=B4=EA=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=B6=80=EB=A5=B4=EB=8A=94=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EB=91=98=EC=9D=B4=20=EC=9D=B8=EC=9E=90=EB=A5=BC=20=EC=95=88=20?= =?UTF-8?q?=EB=84=98=EA=B8=B0=EA=B3=A0=20=EC=9E=88=EC=97=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통로는 났는데 `_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) --- B06_Section/B06_Section_Router_HaulPlan.py | 18 ++++++- .../B06_Section_Server_Calc_Prebuild.py | 33 ++++++++++++- common_util/common_util_mass_haul_balance.ts | 47 +++++++++++++++++-- common_util/common_util_mass_haul_settle.ts | 7 +++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 77c26186..2fc2dc7c 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -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) diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 727e7b56..001dbaf3 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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): diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 6676d4b9..6057b2d8 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -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; diff --git a/common_util/common_util_mass_haul_settle.ts b/common_util/common_util_mass_haul_settle.ts index 8c3923bf..c69bb23f 100644 --- a/common_util/common_util_mass_haul_settle.ts +++ b/common_util/common_util_mass_haul_settle.ts @@ -179,6 +179,13 @@ export function haulPlanPayload(plan: HaulPlan): Record { 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,