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
+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);