Files
Aislo/common_util/common_util_mass_haul_balance_apply.ts
T
eomsangdonandClaude Sonnet 5.5 381f3d0573 refactor(512-5 700 줄 나눔 TS): 유토 배분 결과 타입 · 사토 잔토 · 공제 · 종단 표 고른 측점 열을 새 파일로 — 동작 · 값 그대로
- common_util_mass_haul_balance 842 → 467 (_types · _apply 분리 · 원래 파일에서 타입 다시 내보냄)
- B05_Profile_UI_Profile_Table 702 → 548 (_Table_Selected 분리)
- test_507_3 는 옮긴 글을 새 파일에서 찾음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:46:32 +09:00

258 lines
13 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_balance_apply.ts
* 토량 분배 뒤에 사토에 얹는 몫 — 구조물 잔토(더함) · 채집석 공제(뺌).
* 순서 = 잔토 먼저 · 공제 나중(`computeHaulPlan` 이 부른다).
* ========================================================================== */
import type { EarthworkConversion } from "./common_util_mass_haul_types";
import type { HaulResidual } from "./common_util_mass_haul_balance_types";
import { EPSILON } from "./common_util_mass_haul_curve";
/**
* 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구).
*
* 채집석 공제는 사토에서 한 번만 뺀다.
* B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
* 빼는 자리는 유토곡선의 사토뿐이다 —
* 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
*
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
* ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가
* 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다.
* ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다.
*
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
*/
export function applyCollectedStoneDeduction(
residuals: HaulResidual[],
deduction: number | null,
byGround?: Record<string, number> | null,
groundUnknownM3?: number | null,
conversion?: EarthworkConversion | null,
): number {
// ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라
// 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다.
// 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다).
// 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다.
const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion);
const amountToTake = converted === null ? deduction : converted;
if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0;
const spoils = residuals.filter((residual) => residual.kind === "spoil");
let left = amountToTake;
const take = (residual: HaulResidual, amount: number): void => {
if (amount <= 0) return;
const before = residual.volume_m3;
const ratio = before > 0 ? (before - amount) / before : 0;
residual.volume_m3 = before - amount;
// 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
residual.ea_m3 *= ratio;
residual.rr_m3 *= ratio;
residual.br_m3 *= ratio;
left -= amount;
};
// ① 실어 내는 몫부터
for (const residual of spoils) {
if (left <= EPSILON) break;
take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left));
}
// ② 모자라면 자연방토에서
for (const residual of spoils) {
if (left <= EPSILON) break;
const amount = Math.min(residual.natural_m3, left);
residual.natural_m3 -= amount;
take(residual, amount);
}
return amountToTake - Math.max(left, 0);
}
/**
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
*
* 구조물 잔토는 사토에 한 번만 더한다.
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
* 더하는 자리는 유토곡선의 사토뿐이다.
*
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리).
* ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로
* **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고,
* 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다).
* ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다.
* ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다.
* 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다.
*
* 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다.
*/
/**
* 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다).
* 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다.
*/
function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null {
const text = (ground ?? "").trim();
if (!text) return null;
if (text === "토사" || text === "soil" || text === "ea") return "ea_m3";
if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") {
return "rr_m3";
}
if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3";
return null;
}
/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */
const GROUND_OF_BUCKET = {
ea_m3: "soil",
rr_m3: "ripping_rock",
br_m3: "blasting_rock",
} as const;
/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */
const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const;
/**
* 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로.
* 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다.
*/
function compactedStoneDeduction(
byGround: Record<string, number> | null | undefined,
groundUnknownM3: number | null | undefined,
conversion: EarthworkConversion | null | undefined,
): number | null {
if (!byGround) return null;
const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter(
([, value]) => Number.isFinite(value) && value > 0,
);
const unknown = Number(groundUnknownM3 ?? 0);
const hasUnknown = Number.isFinite(unknown) && unknown > 0;
if (!entries.length && !hasUnknown) return null;
let total = hasUnknown ? unknown : 0;
for (const [ground, value] of entries) {
const factor = conversion?.[ground]?.compacted;
total += typeof factor === "number" && factor > 0 ? value * factor : value;
}
return total;
}
/**
* **자연상태 → 다짐상태**(× 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 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
return {
index: 0,
kind: "spoil",
from_m: fromM,
to_m: toM,
volume_m3: volumeM3,
level_from_m3: 0,
level_to_m3: 0,
ea_m3: 0,
rr_m3: 0,
br_m3: 0,
natural_m3: 0,
};
}
/**
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
*
* 구조물 잔토는 사토에 한 번만 더한다.
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
* 더하는 자리는 유토곡선의 사토뿐이다.
*
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**.
* ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로
* **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
* ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다.
* ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`,
* 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted`
* 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다.
* 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다).
*/
export function applyStructureSpoil(
residuals: HaulResidual[],
amount: number | null,
points: Array<{
chainage_m: number;
spoil_m3: number;
/** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null,
conversion?: EarthworkConversion | null,
): number {
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
// ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
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 spoils = spoilsOf();
const covering = spoils.find(
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
);
// 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라
// 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다.
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
if (!covering) residuals.push(target);
// ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×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;
}
// ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선).
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
const spoils = spoilsOf();
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
if (!spoils.length || total <= EPSILON) {
// 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로
// 구간을 넓게 잡고, **물량은 보존**한다.
const froms = residuals.map((residual) => residual.from_m);
const tos = residuals.map((residual) => residual.to_m);
const fromM = froms.length ? Math.min(...froms) : 0;
const toM = tos.length ? Math.max(...tos) : 0;
residuals.push(newSpoilResidual(fromM, toM, amount));
return amount;
}
let added = 0;
spoils.forEach((residual, index) => {
const share =
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
residual.volume_m3 += share;
added += share;
});
return added;
}