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

# Conflicts:
#	B08_Quantity/B08_Quantity_Router_Earthwork.py
This commit is contained in:
2026-09-09 07:11:49 +09:00
5 changed files with 151 additions and 20 deletions
@@ -69,6 +69,8 @@ if (input.haul_plan_for) {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? 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 })); writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0); process.exit(0);
@@ -95,6 +97,7 @@ const plan = result
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null, structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
}) })
: null; : null;
const massHaul = result const massHaul = result
@@ -225,13 +225,23 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
ground = str(item.get("ground") or "") ground = str(item.get("ground") or "")
distance = item.get("average_distance_m") distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" 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": if key == "free_haul":
note = (note + " · 내역 제외(품에 포함)").strip(" ·") note = (note + " · 내역 제외(품에 포함)").strip(" ·")
rows.append( rows.append(
SummaryRow( SummaryRow(
group=label, group=label,
item=ground, item=ground,
amount=float(item.get("volume_m3") or 0.0), amount=amount,
note=note, note=note,
in_bill=key != "free_haul", in_bill=key != "free_haul",
) )
@@ -17,6 +17,14 @@
인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고 인력운반은 `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` 이다 (이미 있는 값 — 다시 세지 않는다) 입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다)
띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이 띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이
들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다. 들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다.
@@ -27,8 +35,47 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable from typing import Any, Iterable
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다. # 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다.
GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} 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" FREE_HAUL_KEY = "free_haul"
@@ -152,7 +199,13 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
{ {
"equipment": row.equipment, "equipment": row.equipment,
"ground": row.ground, "ground": row.ground,
# ⚠ 이 칸은 **다짐상태**다 — 운반거리를 낸 그 상태 그대로(검산도 이 값으로 한다).
"volume_m3": row.volume_m3, "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, "average_distance_m": row.average_distance_m,
"work_m3m": row.work_m3m, "work_m3m": row.work_m3m,
"legs": row.legs, "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]]: def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.""" """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.
⚠ **두 상태를 함께 넘긴다** — 집계표·내역은 `natural_m3`(자연상태)를 쓰고,
`volume_m3`(다짐상태)는 검산·되짚기용이다. 받는 쪽이 또 환산하지 않게 칸 이름으로 가른다.
"""
return [ return [
{ {
"equipment": row["equipment"], "equipment": row["equipment"],
"ground": row["ground"], "ground": row["ground"],
"volume_m3": row["volume_m3"], "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"], "average_distance_m": row["average_distance_m"],
} }
for row in table.get("rows") or [] 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: 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 []) hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or [])
plan = plan or {} plan = plan or {}
planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0) planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0)
+26 -13
View File
@@ -49,6 +49,7 @@ from common_util.common_util_project_settings import (
) )
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection from config.config_db import run_with_connection
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) 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) 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]: 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] = {} grounds: dict[str, float] = {}
unknown = 0.0 unknown = 0.0
natural_basis = True
# 잔량마다 **사토장까지 거리**가 실려 올 수 있다(2026-09-09 사용자 확정 — 사토장은 이미 # 잔량마다 **사토장까지 거리**가 실려 올 수 있다(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": if str(residual.get("kind") or "") != "spoil":
continue continue
leg_distance = residual.get("spoil_haul_distance_m") leg_distance = residual.get("spoil_haul_distance_m")
# 되돌린 값이 오면 그것이 이긴다 — 우리가 다시 환산하지 않는다(계수가 저쪽에 있다). # ⚠ 잔량은 **다짐상태**로만 읽는다 — 되돌리는 자리는 아래 한 곳뿐이다.
returned = residual.get("natural_m3_by_ground") # 두 곳에서 되돌리면 ÷C 가 두 번 걸린다.
source_map = returned if isinstance(returned, dict) else residual
if not isinstance(returned, dict):
natural_basis = False
for key in ("ea_m3", "rr_m3", "br_m3"): 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: if value > 0:
grounds[key] = grounds.get(key, 0.0) + value grounds[key] = grounds.get(key, 0.0) + value
if isinstance(leg_distance, (int, float)) and float(leg_distance) > 0: 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") deducted = source.get("collected_stone_deducted_m3")
if deducted: if deducted:
note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤") note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤")
if grounds and not natural_basis: # ⚠ **상태를 값으로 낸다**(2026-09-09) — 잔량은 유토곡선이 쌓은 **다짐상태**이고,
note_parts.append( # 내역서에 오르는 수량은 **자연상태**다(`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: if unknown > 0:
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림") note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
return { return {
"volume_m3": round(volume, 3), "volume_m3": round(volume, 3),
"volume_basis": "compacted",
"distance_m": settings.get("spoil_site_distance_m"), "distance_m": settings.get("spoil_site_distance_m"),
"note": " · ".join(note_parts), "note": " · ".join(note_parts),
"by_ground_m3": {key: round(value, 3) for key, value in grounds.items()}, "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), "ground_unknown_m3": round(unknown, 3),
# 갈래별 가중평균 거리 — 사토장이 놓였을 때만 찬다. 비면 설정 거리로 떨어진다. # 갈래별 가중평균 거리 — 사토장이 놓였을 때만 찬다. 비면 설정 거리로 떨어진다.
"distance_by_ground_m": { "distance_by_ground_m": {
key: round(work[key] / metered[key], 2) for key in work if metered.get(key) key: round(work[key] / metered[key], 2) for key in work if metered.get(key)
}, },
# 어느 상태의 물량인가 — 받는 쪽이 단가와 맞는지 스스로 볼 수 있게 값으로 적는다.
"volume_basis": "natural" if natural_basis else "compacted",
} }
+45 -4
View File
@@ -48,6 +48,7 @@
* ========================================================================== */ * ========================================================================== */
import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul"; import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul";
import type { EarthworkConversion } from "./common_util_mass_haul_types";
import { import {
crossFrom, crossFrom,
EPSILON, EPSILON,
@@ -409,6 +410,34 @@ function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "b
return null; 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 { function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰). // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다. // ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
@@ -440,7 +469,11 @@ function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulRes
* *
* ** ()** . * ** ()** .
* ** ** . * ** ** .
* · , . * .
* ** ** B08 ****(`volume_basis: "natural"`,
* ) ****. `toCompacted`
* **×C** . ** **(B08 · ÷C).
* ** ** ( ).
*/ */
function applyStructureSpoil( function applyStructureSpoil(
residuals: HaulResidual[], residuals: HaulResidual[],
@@ -453,6 +486,7 @@ function applyStructureSpoil(
ground_label?: string | null; ground_label?: string | null;
ground?: string | null; ground?: string | null;
}> | null, }> | null,
conversion?: EarthworkConversion | null,
): number { ): number {
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil"); 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 bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
const target = covering ?? newSpoilResidual(chainage, chainage, 0); const target = covering ?? newSpoilResidual(chainage, chainage, 0);
if (!covering) residuals.push(target); if (!covering) residuals.push(target);
target.volume_m3 += value; // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09).
if (bucket) target[bucket] += value; // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다.
added += value; const placed = toCompacted(value, bucket, conversion);
target.volume_m3 += placed;
if (bucket) target[bucket] += placed;
added += placed;
} }
return added; return added;
} }
@@ -520,6 +557,9 @@ export function computeHaulPlan(
ground_label?: string | null; ground_label?: string | null;
ground?: string | null; ground?: string | null;
}> | null; }> | null;
/** () **** .
* ( ). */
conversion?: EarthworkConversion | null;
}, },
): HaulPlan | null { ): HaulPlan | null {
const points = result.points; const points = result.points;
@@ -709,6 +749,7 @@ export function computeHaulPlan(
settled, settled,
structureSpoilInput, structureSpoilInput,
structureSpoilPoints, structureSpoilPoints,
options?.conversion ?? null,
); );
const deductionInput = options?.collected_stone_deduction_m3 ?? null; const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput); const deducted = applyCollectedStoneDeduction(settled, deductionInput);