fix(B08): 운반 물량 상태 되돌림 — 거리는 다짐, 내역 수량은 자연상태

「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에
적용하는 수량은 자연상태로 한다」(설계실무 요령, config 5-4-3 인용문) 인데
유토곡선이 쌓은 다짐 물량이 그대로 내역 줄로 서 있었음.

- HaulSummary: 줄마다 `natural_m3`(÷C)·`volume_basis`·`conversion_c` 를 함께 냄.
  환산은 이 파일 한 곳에서 한 번만. 거리·검산은 다짐 기준 그대로.
  `L`(1.3·1.35·1.625) 은 안 씀 — 품셈 10-11·10-12 가 `f = 1/L` 을 스스로 곱함.
- 토공집계표: 운반 줄을 자연상태로 실음. 갈래를 모르면 다짐 그대로 두고 사유 표기.
- 사토 줄: `natural_m3_by_ground`·`volume_basis` 신설. 갈래 없는 몫은 환산 안 함.
- 구조물 잔토(자연상태)를 곡선에 담기 전 ×C — 왕복이 맞게 됨. 갈래 모르면 그대로.

실측 변화(내역 4줄): 도자 토사 15.65→17.39 · 리핑암 70.30→61.13 ·
덤프 토사 33.76→37.51 · 리핑암 140.78→122.42 (합 260.49→238.45, −8.5%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 07:03:48 +09:00
co-authored by Claude Opus 5
parent fd560eb653
commit e5f540658d
5 changed files with 146 additions and 7 deletions
@@ -69,6 +69,8 @@ if (input.haul_plan_for) {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
@@ -95,6 +97,7 @@ const plan = result
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
})
: null;
const massHaul = result
@@ -225,13 +225,23 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
ground = str(item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
# (config 5-4-3 인용). 유토곡선은 다짐으로 쌓으므로 여기서 ÷C 된 값을 받는다.
# 환산은 `HaulSummary` 한 곳에서만 하고, 여기서는 **고르기만** 한다(두 번 환산 금지).
compacted = float(item.get("volume_m3") or 0.0)
natural = item.get("natural_m3")
amount = float(natural) if isinstance(natural, (int, float)) else compacted
if isinstance(natural, (int, float)):
note = (note + f" · 자연상태 환산(다짐 {compacted:,.2f}㎥ ÷ C)").strip(" ·")
else:
note = (note + " · ⚠ 지반 갈래를 몰라 다짐상태 그대로임").strip(" ·")
if key == "free_haul":
note = (note + " · 내역 제외(품에 포함)").strip(" ·")
rows.append(
SummaryRow(
group=label,
item=ground,
amount=float(item.get("volume_m3") or 0.0),
amount=amount,
note=note,
in_bill=key != "free_haul",
)
@@ -17,6 +17,14 @@
인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고
값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다.
⚠⚠ 상태(狀態)가 두 개다 — **거리는 다짐, 수량은 자연** (2026-09-09)
「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
수량은 자연상태로 한다」(설계실무 요령 — `config_system_design` 5-4-3 인용문).
유토곡선은 다짐상태로 쌓으므로 **가중평균 거리는 그대로 두고**, 내역에 오르는 수량만
`natural_m3`(÷C)로 낸다. **환산은 이 파일 한 곳에서 한 번만** 한다 — 받는 쪽(집계표·인계
줄)은 고르기만 한다. `L`(팽창률)은 쓰지 않는다: 품셈 10-11·10-12 가 `f = 1/L` 을 식 안에서
스스로 곱하므로 우리는 **자연상태 물량만 정확히 넘기면 된다**.
입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다)
띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이
들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다.
@@ -27,8 +35,47 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다.
GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
# 표기 ↔ 환산계수 이름(`EARTHWORK_CONVERSION_FACTORS` 의 키).
GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"}
def _factor_of(ground: str) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다)."""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
return float(entry["compacted"]) if entry else None
def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
"""**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다.
근거 — `config_system_design` 5-4-3 에 이미 적혀 있던 문장이다.
「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고,
내역서에 적용하는 수량은 자연상태로 한다.」
(2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열)
⚠ **나누기다.** `C = 다짐 ÷ 자연` 이므로 되돌리려면 나눠야 한다. 곱하면 토사가
1.111배가 아니라 0.9배가 되어 **방향이 뒤집힌다**(거울 시험이 이 방향을 잠근다).
⚠ **`L`(팽창률 1.3·1.35·1.625)을 쓰지 않는다.** 우리 곡선은 `×C` 로 쌓았으니
되돌리는 것도 `C` 다. 품셈 10-11·10-12 는 `f = 1/L` 을 **식 안에서 스스로** 곱하므로
우리가 `L` 을 또 들면 두 번 환산이 된다.
⚠ **환산은 내보내는 이 자리에서 한 번만.** 곡선 안쪽(띠·이동·잔량)은 다짐상태 그대로 둔다
— 성토 배분은 다짐으로 세는 것이 맞다.
⚠ 갈래를 모르면 `None` 이다 — 토사 계수로 눅이면 근거 없이 금액이 움직인다.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
if not entry:
return None
factor = float(entry["compacted"])
return compacted_volume_m3 / factor if factor > 0 else None
# 무대 — 품에 포함이라 내역 줄이 되지 않는다.
FREE_HAUL_KEY = "free_haul"
@@ -152,7 +199,13 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
{
"equipment": row.equipment,
"ground": row.ground,
# ⚠ 이 칸은 **다짐상태**다 — 운반거리를 낸 그 상태 그대로(검산도 이 값으로 한다).
"volume_m3": row.volume_m3,
"volume_basis": "compacted",
# 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`.
"natural_m3": natural_m3(row.volume_m3, row.ground),
"natural_volume_basis": "natural",
"conversion_c": _factor_of(row.ground),
"average_distance_m": row.average_distance_m,
"work_m3m": row.work_m3m,
"legs": row.legs,
@@ -178,12 +231,19 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다."""
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.
⚠ **두 상태를 함께 넘긴다** — 집계표·내역은 `natural_m3`(자연상태)를 쓰고,
`volume_m3`(다짐상태)는 검산·되짚기용이다. 받는 쪽이 또 환산하지 않게 칸 이름으로 가른다.
"""
return [
{
"equipment": row["equipment"],
"ground": row["ground"],
"volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"),
"conversion_c": row.get("conversion_c"),
"average_distance_m": row["average_distance_m"],
}
for row in table.get("rows") or []
@@ -201,7 +261,11 @@ class HaulCheck:
def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck:
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가."""
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.
⚠ **다짐상태끼리 비교한다** — 계획(`HaulPlan`)이 다짐이라 자연상태로 환산한 값을 대면
늘 어긋난다. 검산은 환산 전 값(`volume_m3`)으로 하는 것이 맞다.
"""
hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or [])
plan = plan or {}
planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0)
@@ -49,6 +49,7 @@ from common_util.common_util_project_settings import (
)
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_FACTORS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
@@ -154,6 +155,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
return JSONResponse(content=table)
#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다.
_COMPACTED_FACTOR = {
"ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]),
"rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]),
"br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]),
}
def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]:
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
@@ -188,11 +197,23 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str
deducted = source.get("collected_stone_deducted_m3")
if deducted:
note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤")
# ⚠ **상태를 값으로 낸다**(2026-09-09) — 잔량은 유토곡선이 쌓은 **다짐상태**이고,
# 내역서에 오르는 수량은 **자연상태**다(config 5-4-3 인용). 여기서 ÷C 한 값을 함께 내
# 받는 쪽이 **또 환산하지 않게** 한다. 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** —
# 토사 계수로 눅이면 근거 없이 금액이 움직인다.
natural_by_ground = {
key: round(value / _COMPACTED_FACTOR[key], 3)
for key, value in grounds.items()
if key in _COMPACTED_FACTOR
}
return {
"volume_m3": round(volume, 3),
"volume_basis": "compacted",
"distance_m": settings.get("spoil_site_distance_m"),
"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),
}
+45 -4
View File
@@ -48,6 +48,7 @@
* ========================================================================== */
import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul";
import type { EarthworkConversion } from "./common_util_mass_haul_types";
import {
crossFrom,
EPSILON,
@@ -409,6 +410,34 @@ function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "b
return null;
}
/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */
const GROUND_OF_BUCKET = {
ea_m3: "soil",
rr_m3: "ripping_rock",
br_m3: "blasting_rock",
} as const;
/**
* **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다.
*
* ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에
* ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라
* 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다.
* ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C).
* 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.**
* ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로
* 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다.
*/
function toCompacted(
naturalM3: number,
bucket: keyof typeof GROUND_OF_BUCKET | null,
conversion: EarthworkConversion | null | undefined,
): number {
if (!bucket || !conversion) return naturalM3;
const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted;
return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3;
}
function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
@@ -440,7 +469,11 @@ function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulRes
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
* ⚠ 자연방토·지반유형 안분은 안 건드린다 — 실어 내는 흙이고, 어느 지반에서 나온지 모른다.
* ⚠ 자연방토 안 건드린다 — 실어 내는 흙이다.
* ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`,
* 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted`
* 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다.
* 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다).
*/
function applyStructureSpoil(
residuals: HaulResidual[],
@@ -453,6 +486,7 @@ function applyStructureSpoil(
ground_label?: string | null;
ground?: string | null;
}> | null,
conversion?: EarthworkConversion | null,
): number {
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
@@ -472,9 +506,12 @@ function applyStructureSpoil(
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
if (!covering) residuals.push(target);
target.volume_m3 += value;
if (bucket) target[bucket] += value;
added += value;
// ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09).
// 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다.
const placed = toCompacted(value, bucket, conversion);
target.volume_m3 += placed;
if (bucket) target[bucket] += placed;
added += placed;
}
return added;
}
@@ -520,6 +557,9 @@ export function computeHaulPlan(
ground_label?: string | null;
ground?: string | null;
}> | null;
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
conversion?: EarthworkConversion | null;
},
): HaulPlan | null {
const points = result.points;
@@ -709,6 +749,7 @@ export function computeHaulPlan(
settled,
structureSpoilInput,
structureSpoilPoints,
options?.conversion ?? null,
);
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);