"""운반 가중평균 — 내역 줄이 되는 4줄과 그 근거 (B08 일감 5 · PLAN 8-3·8-7). 무엇을 내나 실무는 **(운반수단 × 지반유형)별 가중평균 1개**를 내역에 올린다. 울진 실측 — 「도자 토사 1,170㎥ 평균 43.66m · 도자 암 1,554㎥ 39.07m · 덤프 토사 1,667㎥ 293.78m · 덤프 암 1,714㎥ 318.6m」로 **4줄**이다. 오솔길도 분류별 가중평균 1개를 낸다 (거창 무대: 10,399 ÷ 871 = 11.94m). 개별 구간 줄은 버리지 않고 **근거**로 함께 낸다 — 어느 구간이 그 평균을 만들었는지 되짚을 수 있어야 한다. 가중평균 = Σ(토량 × 거리) ÷ Σ(토량) 실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식이다. 단순평균이 아니다. ⚠ 무대(`free_haul`)는 내역 줄이 되지 않는다 (PLAN 8-7 ㉡) 품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다 — 인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. 입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다) 띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이 들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable # 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다. GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} # 무대 — 품에 포함이라 내역 줄이 되지 않는다. FREE_HAUL_KEY = "free_haul" @dataclass(slots=True) class HaulLeg: """근거 줄 하나 — 어느 구간을 얼마나 몇 m 옮겼나.""" equipment: str ground: str volume_m3: float distance_m: float from_m: float to_m: float source: str # `band` 또는 `transfer` @dataclass(slots=True) class HaulSummaryRow: """내역 줄 — (운반수단 × 지반유형) 하나.""" equipment: str ground: str volume_m3: float = 0.0 work_m3m: float = 0.0 # Σ(토량 × 거리) — 가중평균의 분자 legs: int = 0 in_bill: bool = True @property def average_distance_m(self) -> float: return self.work_m3m / self.volume_m3 if self.volume_m3 else 0.0 def _legs_of(plan: dict[str, Any]) -> list[HaulLeg]: """`HaulPlan` → 근거 줄 목록. 띠와 장거리 이동을 같은 모양으로 편다.""" legs: list[HaulLeg] = [] def push( item: dict[str, Any], equipment: str | None, distance: Any, source: str, from_m: Any, to_m: Any, ) -> None: if not equipment or not isinstance(distance, (int, float)): return for key, label in GROUND_LABELS.items(): volume = item.get(key) if not isinstance(volume, (int, float)) or volume <= 0: continue legs.append( HaulLeg( equipment=str(equipment), ground=label, volume_m3=float(volume), distance_m=float(distance), from_m=float(from_m or 0.0), to_m=float(to_m or 0.0), source=source, ) ) for block in plan.get("blocks") or []: for band in block.get("bands") or []: push( band, band.get("equipment"), band.get("haul_distance_m"), "band", band.get("haul_from_m"), band.get("haul_to_m"), ) for transfer in plan.get("transfers") or []: push( transfer, transfer.get("equipment"), transfer.get("haul_distance_m"), "transfer", transfer.get("from_m"), transfer.get("to_m"), ) return legs def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]: """(운반수단 × 지반유형)별 가중평균. 실무 내역이 이 줄들을 그대로 쓴다.""" grouped: dict[tuple[str, str], HaulSummaryRow] = {} for leg in legs: key = (leg.equipment, leg.ground) row = grouped.get(key) if row is None: row = HaulSummaryRow( equipment=leg.equipment, ground=leg.ground, in_bill=leg.equipment != FREE_HAUL_KEY, ) grouped[key] = row row.volume_m3 += leg.volume_m3 row.work_m3m += leg.volume_m3 * leg.distance_m row.legs += 1 # 수단 → 지반유형 순으로 안정 정렬 — 화면·내역 줄 순서가 매번 같아야 한다. order = {FREE_HAUL_KEY: 0, "dozer": 1, "dump_truck": 2} labels = list(GROUND_LABELS.values()) return sorted( grouped.values(), key=lambda row: ( order.get(row.equipment, 9), labels.index(row.ground) if row.ground in labels else 9, ), ) def build_table(plan: dict[str, Any] | None) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.""" legs = _legs_of(plan or {}) rows = summarize(legs) return { "method": "volume_weighted_average", "rows": [ { "equipment": row.equipment, "ground": row.ground, "volume_m3": row.volume_m3, "average_distance_m": row.average_distance_m, "work_m3m": row.work_m3m, "legs": row.legs, "in_bill": row.in_bill, } for row in rows ], # 근거 — 어느 구간이 그 평균을 만들었나. 내역에는 안 오른다. "legs": [ { "equipment": leg.equipment, "ground": leg.ground, "volume_m3": leg.volume_m3, "distance_m": leg.distance_m, "from_m": leg.from_m, "to_m": leg.to_m, "source": leg.source, } for leg in legs ], "bill_row_count": sum(1 for row in rows if row.in_bill), } def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.""" return [ { "equipment": row["equipment"], "ground": row["ground"], "volume_m3": row["volume_m3"], "average_distance_m": row["average_distance_m"], } for row in table.get("rows") or [] ] @dataclass(slots=True) class HaulCheck: """검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡).""" hauled_total_m3: float = 0.0 plan_total_m3: float = 0.0 difference_m3: float = 0.0 details: dict[str, float] = field(default_factory=dict) def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck: """`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.""" 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) by_equipment: dict[str, float] = {} for row in table.get("rows") or []: key = str(row.get("equipment")) by_equipment[key] = by_equipment.get(key, 0.0) + float(row.get("volume_m3") or 0.0) return HaulCheck( hauled_total_m3=hauled, plan_total_m3=planned, difference_m3=hauled - planned, details=by_equipment, )