Files
Aislo/common_util/common_util_mass_haul.ts
T
eomsangdonandClaude Opus 5 c0bfcba34b feat(B05,B06): 유토 배분을 서버로 옮기고 브라우저는 미리 받아 쓰게 함
배분·운반거리 산식(_balance 523 + _settle 235 = 758줄)을 브라우저 번들에서 빼기 위한
배선. 계산은 여전히 한 벌 — 서버가 같은 TS 를 Node 로 돈다(CLAUDE.md 5장).

- POST /projects/{id}/sections/{route_id}/haul-plan 신설
  (B06_Section_Router_HaulPlan.py). 브라우저가 낸 누가토량 결과를 받아 배분만 돌려줌.
  화면이 쓰는 꼴 그대로 내보내 그리기 코드를 안 건드림. 표시 전용 — 정본은 저장 때 따로.
- B05_Profile_Api_HaulPlan.ts 신설 — 편집이 멈추면(400ms) 조용히 받아 두는 선반입기.
  늦게 온 응답은 버림(최신 요청만 채택). 못 받아도 곡선 자체는 그대로 보임.
- common_util_mass_haul.massHaulPayload 가 배분을 값으로 물던 것을 끊음(extra 인자).
  이 커플링 때문에 화면에서 안 불러도 번들에 남았음.
- 저장 경로는 곡선도 배분도 안 만듦 — 서버가 정본을 내므로 balloon 위치만 보냄.
- 죽은 파일 B06_Section_UI_Section_View_MassHaul.ts(236줄) 삭제.

자체검증(공용 브라우저, 용화 route 149) — 엔드포인트 200 · 196ms.
서버 배분과 브라우저 computeHaulPlan 결과가 JSON 문자열까지 동일(blocks 1 · steps 2 ·
spoil/borrow/hauled/transferred/fill_total 전부 일치). 시험 400 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:47:22 +09:00

507 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_mass_haul.ts
* 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다.
* 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해
* 둔다(렌더러는 `common_util_mass_haul_view`).
*
* ── 계산 기준: 다짐상태 ──────────────────────────────────────────────
* "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
* 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열)
* 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태
* 수량은 `cut_natural_m3`로 함께 보존해 B07이 되돌려 쓸 수 있게 한다.
*
* ── 두 기준(종단/횡단)을 함께 내는 이유 ──────────────────────────────
* [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을
* 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은
* 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
*
* ── 절토를 토사/암반으로 나눠 환산하는 이유 ──────────────────────────
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
* 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** —
* 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
*
* 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시).
* 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만,
* 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.**
*
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
* ========================================================================== */
import type {
EarthworkConversion,
GroundType,
MassHaulDesign,
MassHaulLongitudinal,
MassHaulSection,
} from "./common_util_mass_haul_types";
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
export interface GroundVolumes {
soil: number;
ripping_rock: number;
blasting_rock: number;
}
/** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */
export interface MassHaulPoint {
station_id: string;
chainage_m: number;
/** 직전 측점부터 이 측점까지 구간의 순토량(㎥). 첫 측점은 0. */
net_volume_m3: number;
/** 시점부터의 누가토량(㎥). */
cumulative_volume_m3: number;
/** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */
cut_soil_m3: number;
cut_rock_m3: number;
/**
* 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`).
* 운반 블록의 EA/RR/BR 안분(`common_util_mass_haul_balance`)이 이 구간 단위를 되받아 쓴다 —
* 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다.
*/
cut_rr_m3: number;
cut_br_m3: number;
/** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
fill_m3: number;
/**
* 이 구간에서 자연방토가 가능한가 — 양 끝 측점이 **둘 다** 가능할 때만 참(보수적).
* 남는 흙을 성토사면으로 흘려보내 운반비를 세지 않을 수 있는지 판단하는 입력이다.
*/
natural_spoil: boolean;
/**
* 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적.
*
* 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면
* 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비
* 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m).
* 이 값이 있어야 `common_util_mass_haul_balance`가 구간마다 2차식을 세울 수 있다.
*/
net_area_m2: number;
}
export interface MassHaulResult {
points: MassHaulPoint[];
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. 환산 방식과 무관하게 실제 지반유형으로 쌓는다. */
cut_natural_m3: GroundVolumes;
/** 총 절토량(환산 후 합계) — 곡선 기준값. */
cut_compacted_m3: number;
/** 총 성토량(설계 성토량 = 다짐상태). */
fill_compacted_m3: number;
/** 최종 누가토량. 양수면 잉여(사토), 음수면 부족(토취). */
final_cumulative_m3: number;
/** 최종 잉여 토량(사토 대상, ㎥). 부족하면 0. */
surplus_m3: number;
/** 최종 부족 토량(토취 대상, ㎥). 잉여면 0. */
shortage_m3: number;
min_cumulative_m3: number;
max_cumulative_m3: number;
/** 계산에 실제 적용한 환산계수 — 나중에 검산할 수 있도록 함께 보존한다. */
conversion: EarthworkConversion;
}
/** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */
export type MassHaulBasis = "cross" | "longitudinal";
export interface MassHaulSeries {
/** 표시 토글 저장 키이자 CSS 변형 클래스 접미사(= `basis`). */
key: MassHaulBasis;
basis: MassHaulBasis;
result: MassHaulResult;
}
const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"];
/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */
const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];
/**
* 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로
* 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시).
*/
export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"];
/**
* 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에
* 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다.
*/
export const MASS_HAUL_BALANCE_KEY = "balance";
/**
* 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프
* 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기).
* 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 —
* 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다.
*/
export function normalizeVisibleBasis(visible: Set<string>): Set<string> {
const active = SERIES_BASES.filter((basis) => visible.has(basis));
if (active.length === 1) return visible;
const next = new Set(visible);
SERIES_BASES.forEach((basis) => next.delete(basis));
next.add(active[0] ?? SERIES_BASES[0]);
return next;
}
/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */
export function applyLegendToggle(visible: Set<string>, key: string): Set<string> {
const next = new Set(visible);
if ((SERIES_BASES as string[]).includes(key)) {
SERIES_BASES.forEach((basis) => next.delete(basis));
next.add(key);
} else if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return normalizeVisibleBasis(next);
}
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
function emptyVolumes(): GroundVolumes {
return { soil: 0, ripping_rock: 0, blasting_rock: 0 };
}
function finiteArea(value: number | undefined): number {
return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0;
}
function normalizeGround(value: GroundType | undefined): GroundType {
return value && GROUND_TYPES.includes(value) ? value : "soil";
}
/**
* 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다.
* 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분).
*/
interface AreaSample {
station_id: string;
chainage_m: number;
cut_soil_area_m2: number;
cut_rock_area_m2: number;
/** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */
rock_kind: GroundType | null;
fill_area_m2: number;
/** 이 측점에서 자연방토가 가능한가(성토측 지반이 판정 경사보다 가파른가). */
natural_spoil: boolean;
}
/**
* 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점
* 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다.
*/
function splitCut(design: MassHaulDesign | undefined): {
soil: number;
rock: number;
rock_kind: GroundType | null;
} {
const ground = normalizeGround(design?.ground_type);
const total = finiteArea(design?.cut_area_m2);
if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) {
return ground === "soil"
? { soil: total, rock: 0, rock_kind: null }
: { soil: 0, rock: total, rock_kind: ground };
}
return {
soil: finiteArea(design.cut_soil_area_m2),
rock: finiteArea(design.cut_rock_area_m2),
rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground),
};
}
/**
* 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과
* 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다.
*/
function sampleNetArea(sample: AreaSample, conversion: EarthworkConversion): number {
const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil");
const rock = sample.rock_kind
? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind)
: 0;
return soil + rock - sample.fill_area_m2;
}
/**
* 단면적 시퀀스를 누가토량으로 적분한다.
*
* 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
*/
function integrate(samples: AreaSample[], conversion: EarthworkConversion): MassHaulResult | null {
if (samples.length < 2) return null;
const points: MassHaulPoint[] = [
{
station_id: samples[0].station_id,
chainage_m: samples[0].chainage_m,
net_volume_m3: 0,
cumulative_volume_m3: 0,
cut_soil_m3: 0,
cut_rock_m3: 0,
cut_rr_m3: 0,
cut_br_m3: 0,
cut_compacted_m3: 0,
fill_m3: 0,
natural_spoil: samples[0].natural_spoil,
net_area_m2: sampleNetArea(samples[0], conversion),
},
];
const cutNatural = emptyVolumes();
let cutCompacted = 0;
let fillCompacted = 0;
let cumulative = 0;
let minCumulative = 0;
let maxCumulative = 0;
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1];
const current = samples[index];
const spanM = current.chainage_m - previous.chainage_m;
// 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
if (!(spanM > 0)) continue;
const halfSpan = spanM / 2;
// 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다.
let segmentCut = 0;
let segmentCutSoil = 0;
let segmentCutRipping = 0;
let segmentCutBlasting = 0;
for (const end of [previous, current]) {
const soilM3 = end.cut_soil_area_m2 * halfSpan;
const rockM3 = end.cut_rock_area_m2 * halfSpan;
if (soilM3 > 0) {
cutNatural.soil += soilM3;
segmentCutSoil += soilM3;
segmentCut += soilM3 * factorFor(conversion, "soil");
}
if (rockM3 > 0 && end.rock_kind) {
cutNatural[end.rock_kind] += rockM3;
if (end.rock_kind === "blasting_rock") segmentCutBlasting += rockM3;
else segmentCutRipping += rockM3;
segmentCut += rockM3 * factorFor(conversion, end.rock_kind);
}
}
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan;
const net = segmentCut - segmentFill;
cutCompacted += segmentCut;
fillCompacted += segmentFill;
cumulative += net;
minCumulative = Math.min(minCumulative, cumulative);
maxCumulative = Math.max(maxCumulative, cumulative);
points.push({
station_id: current.station_id,
chainage_m: current.chainage_m,
net_volume_m3: net,
cumulative_volume_m3: cumulative,
cut_soil_m3: segmentCutSoil,
cut_rock_m3: segmentCutRipping + segmentCutBlasting,
cut_rr_m3: segmentCutRipping,
cut_br_m3: segmentCutBlasting,
cut_compacted_m3: segmentCut,
fill_m3: segmentFill,
natural_spoil: previous.natural_spoil && current.natural_spoil,
net_area_m2: sampleNetArea(current, conversion),
});
}
if (points.length < 2) return null;
return {
points,
cut_natural_m3: cutNatural,
cut_compacted_m3: cutCompacted,
fill_compacted_m3: fillCompacted,
final_cumulative_m3: cumulative,
surplus_m3: Math.max(cumulative, 0),
shortage_m3: Math.max(-cumulative, 0),
min_cumulative_m3: minCumulative,
max_cumulative_m3: maxCumulative,
conversion,
};
}
/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */
function designedSections(crossSections: MassHaulSection[]): MassHaulSection[] {
return crossSections
.filter((section) => section.design && Number.isFinite(section.chainage_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
}
/**
* 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고
* 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을
* 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다.
*/
function naturalSpoilAllowed(design: MassHaulDesign | undefined, minSlope?: number): boolean {
if (!Number.isFinite(minSlope)) return false;
const slope = design?.fill_ground_slope;
return Number.isFinite(slope) && (slope as number) >= (minSlope as number);
}
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
function crossAreaSamples(sections: MassHaulSection[], minSlope?: number): AreaSample[] {
return sections.map((section) => {
const cut = splitCut(section.design);
return {
station_id: section.station_id,
chainage_m: section.chainage_m,
cut_soil_area_m2: cut.soil,
cut_rock_area_m2: cut.rock,
rock_kind: cut.rock_kind,
fill_area_m2: finiteArea(section.design?.fill_area_m2),
natural_spoil: naturalSpoilAllowed(section.design, minSlope),
};
});
}
/**
* 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적.
*
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다
* (`B05_Profile_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
*
* 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로
* 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다).
*/
function longitudinalAreaSamples(
longitudinal: MassHaulLongitudinal,
sections: MassHaulSection[],
): AreaSample[] {
const profile = longitudinal.design_profiles?.[0];
if (!profile || !sections.length) return [];
const samples = profile.samples
.filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (samples.length < 2) return [];
// 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다.
let cursor = 0;
return samples.map((sample, index) => {
while (
cursor + 1 < sections.length &&
Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <=
Math.abs(sections[cursor].chainage_m - sample.chainage_m)
) {
cursor += 1;
}
const nearest = sections[cursor];
const width = finiteArea(nearest.design?.roadbed_width_m);
const height = sample.difference_m;
const cutArea = height < 0 ? -height * width : 0;
const cut = splitCut(nearest.design);
const total = cut.soil + cut.rock;
const rockRatio = total > 0 ? cut.rock / total : 0;
return {
station_id: `${nearest.station_id}@${index}`,
chainage_m: sample.chainage_m,
cut_soil_area_m2: cutArea * (1 - rockRatio),
cut_rock_area_m2: cutArea * rockRatio,
rock_kind: cut.rock_kind,
fill_area_m2: height > 0 ? height * width : 0,
natural_spoil: false,
};
});
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
* 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
*/
export function computeMassHaul(
crossSections: MassHaulSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
): MassHaulResult | null {
return integrate(
crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope),
conversion,
);
}
/**
* 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다.
* 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
*/
export function computeMassHaulSeries(
longitudinal: MassHaulLongitudinal,
crossSections: MassHaulSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
/** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */
bases: readonly MassHaulBasis[] = SERIES_BASES,
): MassHaulSeries[] {
const sections = designedSections(crossSections);
const samplesFor = (basis: MassHaulBasis): AreaSample[] =>
basis === "cross"
? crossAreaSamples(sections, naturalSpoilMinSlope)
: longitudinalAreaSamples(longitudinal, sections);
const series: MassHaulSeries[] = [];
for (const basis of SERIES_BASES) {
if (!bases.includes(basis)) continue;
const result = integrate(samplesFor(basis), conversion);
if (result) series.push({ key: basis, basis, result });
}
return series;
}
/**
* 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다.
*
* **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고
* 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다.
* 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다.
*/
export function massHaulPayload(
result: MassHaulResult,
extra?: Record<string, unknown> | null,
balloonOffsets?: Record<string, [number, number]>,
): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
basis: "compacted",
conversion: result.conversion,
...(extra ?? {}),
// 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다).
...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}),
cut_natural_m3: {
soil: round(result.cut_natural_m3.soil),
ripping_rock: round(result.cut_natural_m3.ripping_rock),
blasting_rock: round(result.cut_natural_m3.blasting_rock),
},
cut_compacted_m3: round(result.cut_compacted_m3),
fill_compacted_m3: round(result.fill_compacted_m3),
final_cumulative_m3: round(result.final_cumulative_m3),
surplus_m3: round(result.surplus_m3),
shortage_m3: round(result.shortage_m3),
points: result.points.map((point) => ({
station_id: point.station_id,
chainage_m: round(point.chainage_m),
net_volume_m3: round(point.net_volume_m3),
cumulative_volume_m3: round(point.cumulative_volume_m3),
// 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위.
cut_soil_m3: round(point.cut_soil_m3),
cut_rock_m3: round(point.cut_rock_m3),
cut_rr_m3: round(point.cut_rr_m3),
cut_br_m3: round(point.cut_br_m3),
net_area_m2: round(point.net_area_m2),
fill_m3: round(point.fill_m3),
})),
};
}