From a99204c34d8b5463ea0bf0ac07d4de142a1bcf78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:32:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=82=AC=ED=86=A0=EC=9E=A5?= =?UTF-8?q?=EC=9D=B4=20=EC=84=9C=EB=A9=B4=20=EC=82=AC=ED=86=A0=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=EA=B1=B0=EB=A6=AC=EA=B0=80=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=82=98=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사토장이 **측점 위에만** 서므로(사용자 확정 ③) 거리는 「발생점 → 사토장 측점」 누가거리로 그냥 나옴. 가정할 것이 없어짐. - `_spoil_sites` — 측점 설계에 실려 온 사토장 구간값을 구조물 단위로 접음(다시 안 셈) - 거리 = 발생점별 물량 가중평균 + `extra_distance_m`(항상 더함) - 사토장이 없으면 종전대로 설계 입력값 · 그것도 없으면 막힘(임의 거리 안 넣음) - 사유에 사토장 측점·수용량·**못 담는 몫**을 실어 화면이 드러내게 함 - `distance_basis` 칸 신설 — 받는 쪽이 「측점 기준」과 「설계 입력값」을 갈라 봄 ⚠ 사토장에 쌓아도 **유토곡선 사토는 줄지 않음** — 그 흙은 여전히 실어 내야 하는 흙이고 사토장은 목적지임. 줄이면 운반비가 사라짐. (네 창에 확인 요청함) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Router_Earthwork.py | 89 ++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) 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,