diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index faedf92b..480f6126 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -69,6 +69,8 @@ if (input.haul_plan_for) { collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, structure_spoil_points: input.context?.structure_spoil_points ?? null, + // 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다. + conversion: input.context?.earthwork_conversion ?? null, }); writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); process.exit(0); @@ -95,6 +97,7 @@ const plan = result collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, structure_spoil_points: input.context?.structure_spoil_points ?? null, + conversion: conversion ?? null, }) : null; const massHaul = result diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index f94daae3..27a46e93 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -225,13 +225,23 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]: ground = str(item.get("ground") or "") distance = item.get("average_distance_m") note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" + # ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」 + # (config 5-4-3 인용). 유토곡선은 다짐으로 쌓으므로 여기서 ÷C 된 값을 받는다. + # 환산은 `HaulSummary` 한 곳에서만 하고, 여기서는 **고르기만** 한다(두 번 환산 금지). + compacted = float(item.get("volume_m3") or 0.0) + natural = item.get("natural_m3") + amount = float(natural) if isinstance(natural, (int, float)) else compacted + if isinstance(natural, (int, float)): + note = (note + f" · 자연상태 환산(다짐 {compacted:,.2f}㎥ ÷ C)").strip(" ·") + else: + note = (note + " · ⚠ 지반 갈래를 몰라 다짐상태 그대로임").strip(" ·") if key == "free_haul": note = (note + " · 내역 제외(품에 포함)").strip(" ·") rows.append( SummaryRow( group=label, item=ground, - amount=float(item.get("volume_m3") or 0.0), + amount=amount, note=note, in_bill=key != "free_haul", ) diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py index 5653ffc4..b87a3c06 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -17,6 +17,14 @@ 인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. +⚠⚠ 상태(狀態)가 두 개다 — **거리는 다짐, 수량은 자연** (2026-09-09) + 「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 + 수량은 자연상태로 한다」(설계실무 요령 — `config_system_design` 5-4-3 인용문). + 유토곡선은 다짐상태로 쌓으므로 **가중평균 거리는 그대로 두고**, 내역에 오르는 수량만 + `natural_m3`(÷C)로 낸다. **환산은 이 파일 한 곳에서 한 번만** 한다 — 받는 쪽(집계표·인계 + 줄)은 고르기만 한다. `L`(팽창률)은 쓰지 않는다: 품셈 10-11·10-12 가 `f = 1/L` 을 식 안에서 + 스스로 곱하므로 우리는 **자연상태 물량만 정확히 넘기면 된다**. + 입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다) 띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이 들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다. @@ -27,8 +35,47 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS + # 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다. GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} +# 표기 ↔ 환산계수 이름(`EARTHWORK_CONVERSION_FACTORS` 의 키). +GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"} + + +def _factor_of(ground: str) -> float | None: + """그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다).""" + kind = GROUND_KIND_OF.get(ground) + entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None + return float(entry["compacted"]) if entry else None + + +def natural_m3(compacted_volume_m3: float, ground: str) -> float | None: + """**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다. + + 근거 — `config_system_design` 5-4-3 에 이미 적혀 있던 문장이다. + + 「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, + 내역서에 적용하는 수량은 자연상태로 한다.」 + (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) + + ⚠ **나누기다.** `C = 다짐 ÷ 자연` 이므로 되돌리려면 나눠야 한다. 곱하면 토사가 + 1.111배가 아니라 0.9배가 되어 **방향이 뒤집힌다**(거울 시험이 이 방향을 잠근다). + ⚠ **`L`(팽창률 1.3·1.35·1.625)을 쓰지 않는다.** 우리 곡선은 `×C` 로 쌓았으니 + 되돌리는 것도 `C` 다. 품셈 10-11·10-12 는 `f = 1/L` 을 **식 안에서 스스로** 곱하므로 + 우리가 `L` 을 또 들면 두 번 환산이 된다. + ⚠ **환산은 내보내는 이 자리에서 한 번만.** 곡선 안쪽(띠·이동·잔량)은 다짐상태 그대로 둔다 + — 성토 배분은 다짐으로 세는 것이 맞다. + ⚠ 갈래를 모르면 `None` 이다 — 토사 계수로 눅이면 근거 없이 금액이 움직인다. + """ + kind = GROUND_KIND_OF.get(ground) + entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None + if not entry: + return None + factor = float(entry["compacted"]) + return compacted_volume_m3 / factor if factor > 0 else None + + # 무대 — 품에 포함이라 내역 줄이 되지 않는다. FREE_HAUL_KEY = "free_haul" @@ -152,7 +199,13 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]: { "equipment": row.equipment, "ground": row.ground, + # ⚠ 이 칸은 **다짐상태**다 — 운반거리를 낸 그 상태 그대로(검산도 이 값으로 한다). "volume_m3": row.volume_m3, + "volume_basis": "compacted", + # 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`. + "natural_m3": natural_m3(row.volume_m3, row.ground), + "natural_volume_basis": "natural", + "conversion_c": _factor_of(row.ground), "average_distance_m": row.average_distance_m, "work_m3m": row.work_m3m, "legs": row.legs, @@ -178,12 +231,19 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]: def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: - """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.""" + """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다. + + ⚠ **두 상태를 함께 넘긴다** — 집계표·내역은 `natural_m3`(자연상태)를 쓰고, + `volume_m3`(다짐상태)는 검산·되짚기용이다. 받는 쪽이 또 환산하지 않게 칸 이름으로 가른다. + """ return [ { "equipment": row["equipment"], "ground": row["ground"], "volume_m3": row["volume_m3"], + "volume_basis": row.get("volume_basis") or "compacted", + "natural_m3": row.get("natural_m3"), + "conversion_c": row.get("conversion_c"), "average_distance_m": row["average_distance_m"], } for row in table.get("rows") or [] @@ -201,7 +261,11 @@ class HaulCheck: def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck: - """`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.""" + """`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가. + + ⚠ **다짐상태끼리 비교한다** — 계획(`HaulPlan`)이 다짐이라 자연상태로 환산한 값을 대면 + 늘 어긋난다. 검산은 환산 전 값(`volume_m3`)으로 하는 것이 맞다. + """ hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or []) plan = plan or {} planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index e78e5e21..f928aa37 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -49,6 +49,7 @@ from common_util.common_util_project_settings import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) @@ -154,6 +155,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: return JSONResponse(content=table) +#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다. +_COMPACTED_FACTOR = { + "ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]), + "rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]), + "br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]), +} + + def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]: """사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다. @@ -175,7 +184,6 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str # 토사로 눅이면 덤프 단가가 임의로 정해진다. grounds: dict[str, float] = {} unknown = 0.0 - natural_basis = True # 잔량마다 **사토장까지 거리**가 실려 올 수 있다(2026-09-09 사용자 확정 — 사토장은 이미 # 있는 측점 위에만 놓이므로 「발생점 → 사토장 측점」 누가거리로 그냥 나온다). # 갈래별 **가중평균**을 낸다 — 실무 내역이 (운반수단 × 지반)별 평균 하나를 올린다. @@ -185,13 +193,10 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str if str(residual.get("kind") or "") != "spoil": continue leg_distance = residual.get("spoil_haul_distance_m") - # 되돌린 값이 오면 그것이 이긴다 — 우리가 다시 환산하지 않는다(계수가 저쪽에 있다). - returned = residual.get("natural_m3_by_ground") - source_map = returned if isinstance(returned, dict) else residual - if not isinstance(returned, dict): - natural_basis = False + # ⚠ 잔량은 **다짐상태**로만 읽는다 — 되돌리는 자리는 아래 한 곳뿐이다. + # 두 곳에서 되돌리면 ÷C 가 두 번 걸린다. for key in ("ea_m3", "rr_m3", "br_m3"): - value = float(source_map.get(key) or source_map.get(key[:2]) or 0.0) + value = float(residual.get(key) or 0.0) if value > 0: grounds[key] = grounds.get(key, 0.0) + value if isinstance(leg_distance, (int, float)) and float(leg_distance) > 0: @@ -207,24 +212,32 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str deducted = source.get("collected_stone_deducted_m3") if deducted: note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤") - if grounds and not natural_basis: - note_parts.append( - "⚠ 다짐상태 물량 — 품셈 운반 밑수는 자연상태라 유토곡선이 되돌린 값이 오면 그것을 씀" - ) + # ⚠ **상태를 값으로 낸다**(2026-09-09) — 잔량은 유토곡선이 쌓은 **다짐상태**이고, + # 내역서에 오르는 수량은 **자연상태**다(`config_system_design` 5-4-3 「운반거리 산정 시 + # 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 수량은 자연상태로 한다**」). + # 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다. + # ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이 + # 금액이 움직인다. 그 사실을 사유로 낸다. + natural_by_ground = { + key: round(value / _COMPACTED_FACTOR[key], 3) + for key, value in grounds.items() + if key in _COMPACTED_FACTOR + } if unknown > 0: note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림") return { "volume_m3": round(volume, 3), + "volume_basis": "compacted", "distance_m": settings.get("spoil_site_distance_m"), "note": " · ".join(note_parts), "by_ground_m3": {key: round(value, 3) for key, value in grounds.items()}, + "natural_m3_by_ground": natural_by_ground, + "natural_volume_basis": "natural", "ground_unknown_m3": round(unknown, 3), # 갈래별 가중평균 거리 — 사토장이 놓였을 때만 찬다. 비면 설정 거리로 떨어진다. "distance_by_ground_m": { key: round(work[key] / metered[key], 2) for key in work if metered.get(key) }, - # 어느 상태의 물량인가 — 받는 쪽이 단가와 맞는지 스스로 볼 수 있게 값으로 적는다. - "volume_basis": "natural" if natural_basis else "compacted", } diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 5bc1f8c0..2717c2dc 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -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);