"""사토 — 실어 내는 줄 (2026-09-08). ⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 `blocks[].bands` 와 `transfers` 에서만 만들어지는데, 사토는 `residuals(kind="spoil")` 로 남아 **어느 쪽에도 없다.** 그래서 구조물 잔토를 사토에 얹어도(126.63㎥) **덤프 물량이 하나도 안 늘었다** — 채집석 공제도 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**. 실무에는 서는 줄이다 — 울진 대흥 1공구 토적집계 `D32 사토 1,281㎥`. ⚠ **거리는 품셈이 정하지 않는다.** 사토장까지 거리는 설계 입력(`spoil_site_distance_m`)이고, 안 정했으면 **막고 사유를 낸다** — 임의 거리를 넣으면 그대로 금액이 된다. ⚠ **지반 갈래는 유토곡선이 준 것만 쓴다.** 잔량이 갈래별 물량(`ea/rr/br`)을 들고 오면 **갈래마다 한 줄**로 세운다 — 덤프 단가가 토사·암으로 갈리기 때문이다. 갈래를 못 붙인 몫(`ground_unknown_m3`)은 **따로 한 줄**로 세우고 막는다. 토사로 눅이면 임의 단가가 된다. """ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_INPUT_MISSING, ORIGIN_HAUL, WorkItemMapping, ) from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts SPOIL_NAME = "사토 운반" #: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을 #: 다듬고 보호공」을 요구하나 **실무 내역·사방공 집계표에 사토장 행이 아예 없다**(비탈 다듬기· #: 면고르기 둘 다 0). 프로젝트 규칙이 「기본값 = 현행 법령·행정규칙, 교본은 과거 참조」이고 #: 실무가 안 세므로 안 세는 쪽이 기본이다 — **뒤집히면 이 문장만 지우면 된다.** #: ⚠ **「사토장 정지」도 별도 줄이 아니다** — 실무 단가산출근거가 「사토장정지(굴삭기 0.7㎥) #: 1/3 적용」으로 **사토 운반 단가 안 조각**으로 넣는다(랩탑 보조 실물 확인). 수량 줄을 #: 따로 세우면 같은 품을 두 번 센다. SPOIL_SCOPE_NOTE = ( "사토장 정지는 이 단가 안 조각(실무 「사토장정지 1/3 적용」) · 사토장 사면 보호공은" " 실무 집계표에 행이 없어 안 셈(교본 6장 3절은 요구 — 뒤집으면 그때 셈)" ) SPOIL_EQUIPMENT = "dump_truck" #: 거리가 어디서 왔나 — 값 옆에 적는다(사토장에서 온 것과 대체 입력을 가른다). DISTANCE_FROM_SITE = "사토장 측점까지 누가거리(갈래별 가중평균)" DISTANCE_FROM_SETTING = "산출 조건의 대체 거리 — 사토장을 놓으면 그 값이 이김" #: ⚠ **사토장을 놓으면 거리가 저절로 나온다**(2026-09-09 사용자 확정 — 사토장은 이미 있는 #: 측점 위에만 놓이므로 「발생점 → 사토장 측점」 누가거리). 그래서 사유도 「거리가 없다」가 #: 아니라 **「사토장을 아직 안 놓았다」**로 말한다. 설계 입력 칸은 **안 놓았을 때의 대체값**이다. DISTANCE_MISSING = ( "사토장을 아직 안 놓았고 대체 거리도 없어 값이 서지 않음 — 사토장을 노선에 놓으면" " 그 측점까지 거리가 계산되고, 그 전에는 산출 조건의 「사토장까지 거리」가 대신 씀" "(임의 거리를 넣으면 그대로 금액이 됨)" ) GROUND_UNKNOWN = ( "지반 갈래를 못 붙인 몫 — 구조물 잔토 가운데 걸친 측점의 지반이 섞여 못 가른 것." " ⚠ 토사로 눅이면 덤프 단가가 임의로 정해짐" ) #: 잔량이 들고 오는 갈래 키 ↔ 우리 갈래 이름(흙깎기·운반이 쓰는 그 낱말). GROUND_KEYS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} def spoil_haul_rows( haul_table: dict[str, Any] | None, mapping: WorkItemMapping ) -> list[dict[str, Any]]: """사토를 실어 내는 줄 하나. 사토가 없으면 줄도 없다.""" spoil = (haul_table or {}).get("spoil") or {} volume = float(spoil.get("volume_m3") or 0.0) if volume <= 0: return [] distance = spoil.get("distance_m") entry = mapping.for_haul(SPOIL_EQUIPMENT) or {} code = entry.get("work_item_code") blocked = distance is None or float(distance) <= 0 reason = DISTANCE_MISSING if blocked else "" note = spoil.get("note") or "" # 갈래별로 나눠 세운다 — 덤프 단가가 토사·암으로 갈린다. 갈래가 안 오면 종전처럼 한 줄. # ⚠⚠ **내역 수량은 자연상태다**(`config_system_design` 5-4-3). 사토 잔량은 유토곡선이 # 쌓은 다짐상태라 되돌린 값(`natural_m3_by_ground`)이 오면 **그 값으로 선다**. # 갈래를 못 붙인 몫은 되돌릴 계수가 없어 다짐 그대로 서고, 그 사실이 사유에 남는다. returned = spoil.get("natural_m3_by_ground") or {} natural_basis = bool(returned) source_map = returned if natural_basis else (spoil.get("by_ground_m3") or {}) by_ground = { GROUND_KEYS[key]: float(value) for key, value in source_map.items() if key in GROUND_KEYS and float(value or 0.0) > 0 } state_note = ( "자연상태(유토곡선이 ÷C 로 되돌린 값)" if natural_basis else "⚠ 다짐상태 그대로 — 되돌린 값이 아직 안 옴(내역 수량은 자연상태여야 함)" ) # 갈래별 거리 — 사토장이 놓였으면 그 값이 이긴다(정확한 값이 이김). by_distance = { GROUND_KEYS[key]: float(value) for key, value in (spoil.get("distance_by_ground_m") or {}).items() if key in GROUND_KEYS and float(value or 0.0) > 0 } unknown = float(spoil.get("ground_unknown_m3") or 0.0) if by_ground or unknown > 0: rows: list[dict[str, Any]] = [] # 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`). shares = (haul_table or {}).get("rock_shares") for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares): leg = by_distance.get(source) leg_blocked = blocked if leg is None else False rows.append( _spoil_row( code, amount, distance if leg is None else leg, leg_blocked or bool(share_reason), share_reason or (reason if leg_blocked else ""), note, ground=label, extra=" · ".join( part for part in ( f"암 갈래 {rock_class}" if rock_class else "", DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note, ) if part ), ) ) if unknown > 0: rows.append( _spoil_row( code, unknown, distance, True, GROUND_UNKNOWN, note, ground=None, extra=GROUND_UNKNOWN, ) ) return _with_loading(rows) return _with_loading( [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)] ) def _with_loading(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """사토도 싣는 일이 따로 — 흙깎기 운반과 같은 「덤프 적재」 짝 줄(10-12 「1. 적재」). 내역에 서는 운반 줄에만 붙음 · 거리 무관 · 수량은 운반량 그대로(2026-09-14 브레인 판정). 운반토량 검산은 `dump_loading` 을 빼므로 두 번 안 셈. """ out: list[dict[str, Any]] = [] for row in rows: out.append(row) if row["in_bill"]: out.append( { **row, "name": "덤프 적재", "spec": row["ground_class"] or "", "haul_distance_m": None, "haul_equipment": LOADING_EQUIPMENT, "spec_detail": f"{row['spec_detail']} · 사토량만큼 싣기(10-12 「1. 적재」)", } ) return out def _spoil_row( code: str | None, volume: float, distance: Any, blocked: bool, reason: str, note: str, ground: str | None, extra: str, ) -> dict[str, Any]: """사토 운반 줄 하나 — 갈래마다 같은 모양으로 낸다.""" return { "work_item_code": code, "name": SPOIL_NAME, "spec": " · ".join( part for part in (ground or "", "" if blocked else f"{float(distance):g}m") if part ), "unit": "㎥", "quantity": round(volume, 3), "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": ground, "haul_distance_m": None if blocked else float(distance), "haul_equipment": SPOIL_EQUIPMENT, "station_from": None, "station_to": None, "excavation_method": None, "spec_detail": " · ".join(part for part in (note, extra, SPOIL_SCOPE_NOTE) if part), "composite_parts": None, "structure_kind": None, "blocked_kind": BLOCKED_INPUT_MISSING if blocked else None, "blocked_reason": reason, # 갈래는 `variant_value` 한 통로로 — 덤프 운반·적재 단가가 토사·암으로 갈림 # (종전 None 이라 B09 가 10-12 잎을 못 골라 사토 운반이 「갈래 미지정」으로 막힘). "variant_axis": "ground_class" if ground else None, "variant_value": ground, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", "composite_not_ready": None, "in_bill": not blocked and code is not None, "in_bill_reason": reason, "origin": ORIGIN_HAUL, }