Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-09 06:38:13 +09:00
24 changed files with 1288 additions and 30 deletions
+164 -1
View File
@@ -199,6 +199,13 @@ export interface HaulPlan {
collected_stone_deduction_m3: number | null;
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
collected_stone_deducted_m3: number;
/**
* 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고
* `0` 은 「없음」이다(공제와 같은 태도).
*/
structure_spoil_m3: number | null;
/** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */
structure_spoil_added_m3: number;
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
@@ -334,6 +341,9 @@ function bandOutline(
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
* ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가
* 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다.
* ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다.
*
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
*/
@@ -367,10 +377,150 @@ function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: numb
return deduction - 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;
}
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, ㎥ 양수)을 내기만 하고,
* 더하는 자리는 유토곡선의 사토뿐이다.
*
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**.
* ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로
* **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
* ⚠ 자연방토·지반유형 안분은 안 건드린다 — 실어 내는 흙이고, 어느 지반에서 나온지 모른다.
*/
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,
): 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);
target.volume_m3 += value;
if (bucket) target[bucket] += value;
added += value;
}
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;
}
export function computeHaulPlan(
result: MassHaulResult,
limits: HaulEquipmentLimit[] | undefined,
options?: { collected_stone_deduction_m3?: number | null },
options?: {
collected_stone_deduction_m3?: number | null;
structure_spoil_m3?: number | null;
/** 측점별 구조물 잔토 — 오면 **이쪽이 이긴다**(그 자리 잔량에 얹어 운반거리를 맞춘다). */
structure_spoil_points?: Array<{
chainage_m: number;
spoil_m3: number;
/** 그 터파기의 토질 — B08 이 `design.ground_type` 으로 이미 판정한 값(품셈 9-13 3구분).
* **새 근거를 만드는 것이 아니라 이어받는 것**이다. 섞여서 못 고른 구조물은 `null` 로
* 오고, 그 몫은 「지반 모름」(`ground_unknown_m3`)으로 남는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null;
},
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
@@ -549,6 +699,17 @@ export function computeHaulPlan(
residual.index = index + 1;
});
// ⚠ **순서가 뜻을 가른다 — 「잔토를 더하고 → 공제를 뺀다」**(2026-09-09 확정).
// 유토곡선의 사토는 「현장에 남는 흙 총량」이고 **출처를 안 가린다**. 공제는 그 총량에서
// 빼는 것이라 **잔토가 담긴 뒤라야 뺄 대상이 있다.** 반대 순서로 두면 사토가 0 인
// 노선(전 구간 토취)에서 공제가 **영영 안 걸린다** — 두 창 저장분에서 실제로 그랬다.
const structureSpoilInput = options?.structure_spoil_m3 ?? null;
const structureSpoilPoints = options?.structure_spoil_points ?? null;
const structureSpoilAdded = applyStructureSpoil(
settled,
structureSpoilInput,
structureSpoilPoints,
);
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
@@ -577,6 +738,8 @@ export function computeHaulPlan(
natural_spoil_m3: naturalSpoil,
collected_stone_deduction_m3: deductionInput,
collected_stone_deducted_m3: deducted,
structure_spoil_m3: structureSpoilInput,
structure_spoil_added_m3: structureSpoilAdded,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
fill_total_m3: fillTotal,
@@ -179,6 +179,13 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 구조물 몫 — **받은 값**과 **실제로 먹은 값**을 함께 남긴다. 「통로만 있고 값이 안 흐른다」를
// 저장분에서 바로 가릴 수 있어야 한다(2026-09-09 그 사고가 세 번 났다).
// `null` 은 「아직 안 옴」, `0` 은 「없음」이다 — 눅이지 않는다.
collected_stone_deduction_m3: plan.collected_stone_deduction_m3,
collected_stone_deducted_m3: round(plan.collected_stone_deducted_m3),
structure_spoil_m3: plan.structure_spoil_m3,
structure_spoil_added_m3: round(plan.structure_spoil_added_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
@@ -230,6 +237,13 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
// ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가
// 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를
// 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다
// (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다).
ground_unknown_m3: round(
Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0),
),
})),
};
}
@@ -115,6 +115,18 @@ def default_settings() -> dict[str, Any]:
# ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
# 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다.
"bench_cut_depth_m": None,
# 사토장까지 운반거리(m) — 설계 입력이다. 품셈은 거리를 정하지 않는다.
# ⚠ `None` 은 「안 정함」이고, 그러면 사토 운반 줄이 **막힌 채로** 선다.
# (2026-09-09 확정 3차 ③ — 권고할 값이 없어 **물음으로 남긴 자리**다.)
"spoil_site_distance_m": None,
# 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
# ⚠ 품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않는다. 폭은 버림 폭과 같고
# (KCS 34 50 05) 두께가 이 값이다. 화면에서 바꿀 수 있다.
"rubble_base_thickness_m": 0.2,
# 구조물터파기 용수 유무 — 품셈 9-13 의 18구분 중 한 축.
# ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을
# 적어 두고 사용자가 뒤집을 수 있게 둔다.
"structure_trench_water": "육상",
"dataset_versions": {},
},
"estimation": {
@@ -32,6 +32,8 @@ export interface WallSpec {
form: string | null;
height_m: number | null;
side: string | null;
/** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */
foundation: string | null;
tiers: number | null;
lift_m: number | null;
shift_m: number | null;
@@ -78,6 +80,8 @@ export function wallSpecsFrom(
form: (options.form as string) || FORM_BY_TYPE[structure.type_id] || null,
height_m: num(options.height_m),
side: (options.side as string) ?? null,
// 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다.
foundation: (options.foundation as string) ?? null,
tiers: num(options.tiers),
lift_m: num(options.lift_m),
shift_m: num(options.shift_m),