"""유토곡선이 받아야 할 **구조물 몫** — 채집석 공제와 구조물 잔토 (2026-09-08). ⚠⚠ **B08 은 내기만 하고 빼거나 더하지 않는다.** 두 값 다 **양수 ㎥** 로 주고, 공제(빼기)와 사토 가산(더하기)은 **유토곡선(B06)에서 한 번씩만** 일어난다. 부호를 넘기면 받는 쪽에서 두 번 뒤집힌다 — 실무 시트가 `−274.66` 으로 적혀 있어 실제로 겪은 자리다. 채집석 공제는 사토에서 한 번만 뺀다. 구조물 잔토는 사토에 한 번만 더한다. ⚠ **측점별로도 낸다.** 총량 하나만 주면 받는 쪽이 잔량 크기에 비례해 나눌 수밖에 없고, 그러면 **운반거리가 틀어진다**(한 곳에 몰리면 거리가 어긋남 — 공제 때 이미 짚은 자리). 구조물은 구간(start~end)이라 **그 가운데 측점**을 자리로 본다. ⚠ **지반 갈래도 함께 낸다** — 새 판정이 아니라 **구조물터파기가 이미 쓰는 그 값**이다 (`design.ground_type` 에서 뽑아 「암절취 · 심도 2~3m」로 세운 그것). 그 터파기에서 나온 흙이 곧 이 잔토이므로 **같은 판정을 두 곳이 그대로 쓴다** — 두 벌로 짜면 갈린다. ⚠ **섞여서 못 고른 구조물은 「모름」(`None`)으로 보낸다** — 터파기에서 다수결로 안 고른 그 규칙 그대로다. 받는 쪽이 그 몫을 「모르는 몫」으로 드러내면 된다. ⚠ **한계도 그대로 넘어간다** — 「터파기 깊이가 암반 경계선보다 깊은지」는 보지 않는다. 터파기 토질에 이미 있는 한계이고 여기서 새로 생기는 것이 아니다. """ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_UnitQuantity import COLLECTED_STONE_KEY, GROUND_TYPE_LABEL #: 유토곡선이 읽는 칸 이름 — 양쪽이 같은 낱말을 써야 인계에서 어긋나지 않는다. STRUCTURE_SPOIL_KEY = "structure_spoil_m3" STRUCTURE_SPOIL_POINTS_KEY = "structure_spoil_points" #: ⚠⚠ **이 값은 자연상태(본바닥)다.** 유토곡선 잔량은 **다짐상태**라 그냥 더하면 상태가 #: 섞인다(2026-09-09 랩탑 메인 지적). 받는 쪽이 `× C` 로 맞춰 담을 수 있게 **상태를 값으로 #: 적어 보낸다** — 말로만 두면 다음 사람이 못 본다. #: 까닭: 터파기·되메우기가 **제자리 기하 부피**(H × 폭 × 연장)이고 잔토는 그 차이다. #: ⚠ 엄밀히는 되메움에 **쓰이는 흙**은 다짐부피 ÷ C 라 조금 더 든다. 실무 정본 시트도 #: 그냥 빼므로(터파기 1.55 − 되메우기 0.30 = 잔토 1.25) 여기서도 그대로 두되, #: **그 사실을 적어 둔다** — 값을 임의로 보정하지 않는다. VOLUME_BASIS_KEY = "volume_basis" VOLUME_BASIS_NATURAL = "natural" SPOIL_COMPONENT = "잔토처리" COLLECTED_STONE_COMPONENT = "채집석" #: 채집석 공제를 **갈래별로** 낸다 (2026-09-09 세 창 확정 — 축을 맞추기로). #: ⚠ 이 값은 **벽 입적(제자리 완성 부피)**이다 — 자연·다짐 어느 축으로도 환산한 적 없다. #: 굳이 가르면 자연 축 쪽이라 받는 쪽이 **×C 로 다짐 축에 맞춰** 뺀다. #: ⚠⚠ **벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없다** — 캐면 부풀고(L) 벽에 쌓으면 #: 공극이 생기는데 그 값이 품셈·교본·지식DB 어디에도 없다. **실무 시트도 그냥 뺀다** #: (울진 「채집석 −274.66」이 사토에서 바로 빠진다). 그 가정은 어느 쪽으로 가도 남으므로 #: **가정은 그대로 두고 축만 맞춘다** — 축이 다른 값을 그냥 빼는 것은 우리가 만드는 어긋남이다. #: ⚠ 갈래를 모르는 구조물 몫은 **환산하지 않는다**(계수가 없다) — 따로 낸다. COLLECTED_STONE_BY_GROUND_KEY = "collected_stone_by_ground_m3" COLLECTED_STONE_UNKNOWN_KEY = "collected_stone_ground_unknown_m3" #: ⭐ **축 맞춤의 검산** — 더하는 쪽(구조물 잔토)과 빼는 쪽(채집석 공제)에 **같은 계수**가 #: 걸리므로 사토 총량은 안 흔들린다. 실측: 잔토 126.625 ×1.15 = 145.62 담기고 공제 #: 64.75 ×1.15 = 74.46 빠져 사토(다짐) 71.16 → ÷1.15 = **61.887**, 축 맞추기 전 61.88 과 같다. #: ⇒ **값을 안 흔들면서 축만 바로잡은 것**이다. 갈래가 갈리거나 모르는 몫이 섞이는 #: 자리에서만 값이 달라진다. COLLECTED_STONE_AXIS_CHECK = ( "축 맞춤 검산 — 더하는 쪽과 빼는 쪽에 같은 계수가 걸려 사토 총량은 그대로" "(잔토 ×C 담김 · 공제 ×C 빠짐 ⇒ ÷C 하면 같은 값)" ) COLLECTED_STONE_BASIS_NOTE = ( "벽 입적(제자리 부피) — 자연 축으로 보고 받는 쪽이 ×C 로 다짐 축에 맞춰 뺌." " ⚠ 벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없음(캐면 부풀고 쌓으면 공극이 생기나" " 그 값이 어디에도 없음). 실무 시트는 그냥 뺌 — 우리는 축만 맞춤" ) def _center(structure: dict[str, Any]) -> float | None: start, end = structure.get("start_m"), structure.get("end_m") if start is None and end is None: return None values = [float(v) for v in (start, end) if v is not None] return sum(values) / len(values) def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]: """(채집석 공제, 구조물 잔토, 측점별 잔토). 값이 없으면 `None` — 0 으로 눅이지 않는다. ⚠ `None` 과 `0.0` 은 다르다. 「아직 안 옴」과 「없음」을 받는 쪽이 갈라 봐야 한다. """ if not unit_quantity_table: return { COLLECTED_STONE_KEY: None, COLLECTED_STONE_BY_GROUND_KEY: {}, COLLECTED_STONE_UNKNOWN_KEY: None, STRUCTURE_SPOIL_KEY: None, STRUCTURE_SPOIL_POINTS_KEY: [], VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL, } total = 0.0 points: list[dict[str, Any]] = [] stone_by_ground: dict[str, float] = {} stone_unknown = 0.0 for structure in unit_quantity_table.get("structures") or []: amount = 0.0 stone = 0.0 for component in structure.get("components") or []: name = str(component.get("name") or "") if name == COLLECTED_STONE_COMPONENT: stone += float(component.get("amount") or 0.0) if name != SPOIL_COMPONENT: continue amount += float(component.get("amount") or 0.0) # 채집석 — 그 구조물의 지반 갈래로 담는다. 갈래를 못 가른 구조물 몫은 따로 둔다. if stone > 0: ground = str(structure.get("ground_type") or "") if ground: stone_by_ground[ground] = stone_by_ground.get(ground, 0.0) + stone else: stone_unknown += stone if amount <= 0: continue total += amount chainage = _center(structure) if chainage is None: continue # 지반 갈래 — 구조물터파기가 쓰는 그 판정을 그대로 싣는다(두 벌로 안 짠다). ground = structure.get("ground_type") or None points.append( { "chainage_m": chainage, "spoil_m3": round(amount, 3), VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL, "ground_type": ground, "ground_label": GROUND_TYPE_LABEL.get(str(ground)) if ground else None, "ground_basis": str(structure.get("ground_type_basis") or ""), } ) collected = unit_quantity_table.get(COLLECTED_STONE_KEY) return { COLLECTED_STONE_KEY: collected, COLLECTED_STONE_BY_GROUND_KEY: { key: round(value, 3) for key, value in sorted(stone_by_ground.items()) }, COLLECTED_STONE_UNKNOWN_KEY: round(stone_unknown, 3) if stone_unknown else 0.0, "collected_stone_basis": f"{COLLECTED_STONE_BASIS_NOTE} · {COLLECTED_STONE_AXIS_CHECK}", STRUCTURE_SPOIL_KEY: round(total, 3) if points or total else None, STRUCTURE_SPOIL_POINTS_KEY: sorted(points, key=lambda row: row["chainage_m"]), # 총량에도 상태를 적는다 — 측점값을 안 쓰는 쪽도 상태를 알아야 한다. VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL, }