diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index f928aa37..326c22bf 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -87,7 +87,7 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. - haul["spoil"] = _spoil_of(plan, settings) + haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. table["pipe_lengths"] = [ @@ -163,7 +163,75 @@ _COMPACTED_FACTOR = { } -def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]: +def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다. + + ⚠ 여기서 **다시 세지 않는다** — 용량·담긴 양은 B06 이 정한 값이고, 이 함수는 그것을 + 구조물 단위로 접어 「어디에 얼마나 담기나」만 만든다. + """ + sites: dict[str, dict[str, Any]] = {} + for row in designs: + design = (row or {}).get("design") or {} + key = str(design.get("spoil_fill_structure_id") or "") + if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0: + continue + chainage = float(row.get("chainage_m") or 0.0) + site = sites.setdefault( + key, + { + "structure_id": key, + "from_m": chainage, + "to_m": chainage, + "capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0), + "placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0), + "unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0), + "extra_distance_m": design.get("spoil_fill_extra_distance_m"), + }, + ) + site["from_m"] = min(site["from_m"], chainage) + site["to_m"] = max(site["to_m"], chainage) + for site in sites.values(): + site["center_m"] = (site["from_m"] + site["to_m"]) / 2 + return sorted(sites.values(), key=lambda item: item["center_m"]) + + +def _site_distance_m( + sites: list[dict[str, Any]], residuals: list[dict[str, Any]] +) -> tuple[float | None, str]: + """사토장까지의 **가중평균 운반거리**(m)와 근거 문구. + + 「발생점 → 사토장 측점」 누가거리다(2026-09-09 사용자 확정 ③ — 사토장이 측점 위에만 + 서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다. + ⚠ 사토장이 없으면 `None` — 설계 입력(`spoil_site_distance_m`)으로 되돌아간다. + **임의 거리를 넣지 않는다**(그대로 금액이 된다). + """ + if not sites: + return None, "" + work = 0.0 + volume = 0.0 + for residual in residuals: + if str(residual.get("kind") or "") != "spoil": + continue + amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0) + if amount <= 0: + continue + center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2 + nearest = min(sites, key=lambda site: abs(site["center_m"] - center)) + extra = nearest.get("extra_distance_m") + distance = abs(nearest["center_m"] - center) + float(extra or 0.0) + work += amount * distance + volume += amount + if volume <= 0: + return None, "" + where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites) + return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균" + + +def _spoil_of( + plan: dict[str, Any] | None, + settings: dict[str, Any], + sites: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: """사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다. ⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤). @@ -225,10 +293,25 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str } if unknown > 0: note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림") + # 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값. + site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or []) + if site_distance is not None: + note_parts.append(site_basis) + placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or []) + unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or []) + note_parts.append(f"사토장 수용 {placed:,.1f}㎥") + if unplaced > 0: + note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함") return { "volume_m3": round(volume, 3), "volume_basis": "compacted", - "distance_m": settings.get("spoil_site_distance_m"), + "distance_m": ( + round(site_distance, 3) + if site_distance is not None + else settings.get("spoil_site_distance_m") + ), + "distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"), + "sites": sites or [], "note": " · ".join(note_parts), "by_ground_m3": {key: round(value, 3) for key, value in grounds.items()}, "natural_m3_by_ground": natural_by_ground,