- 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>
258 lines
13 KiB
TypeScript
258 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|