From 759343e3848ee3913062131f7eb3be25d5fc8bef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 23:05:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(B08):=20=EC=82=AC=ED=86=A0=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=20=EC=A4=84=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EB=82=B8=20?= =?UTF-8?q?=EC=82=AC=ED=86=A0=EB=A5=BC=20=EC=95=84=EB=AC=B4=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=EC=96=B4=20=EB=82=B4=EC=A7=80=20=EC=95=8A=EC=95=98?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운반 줄이 띠(bands)·이동(transfers)에서만 만들어져 사토는 잔량(residuals)으로 남아 어디에도 안 실렸음. 그래서 구조물 잔토 126.63㎥ 를 얹어도 덤프가 안 늘었고, 채집석 공제도 사토를 줄이는 값이라 끝까지 금액에 안 나타났음 - 실무에는 서는 줄(울진 대흥 1공구 토적집계 D32 사토 1,281㎥) - 운반표에 `spoil`(물량·거리·근거)을 실어 인계가 「사토 운반」 한 줄을 세움 - 물량은 유토곡선 결과 그대로 — 공제·가산이 끝난 값이라 여기서 또 만지지 않음. 자연방토는 실어 내지 않으므로 뺌 - 거리는 설계 입력 `spoil_site_distance_m` 신설 — 안 정했으면 막고 사유 (품셈이 정하는 값이 아니라 임의 거리가 그대로 금액이 됨) - 지반 갈래는 유토곡선이 안분하지 않아 갈래 없이 한 줄, 그 사실을 근거에 - tmp/tests/test_b08_spoil_haul.py 신설(6건) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 4 + .../B08_Quantity_Engine_Handoff_Spoil.py | 81 +++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 36 ++++++++- common_util/common_util_project_settings.py | 3 + 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 12586660..e29e2762 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -43,6 +43,7 @@ from __future__ import annotations from typing import Any, Iterable from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis +from B08_Quantity.B08_Quantity_Engine_Handoff_Spoil import spoil_haul_rows from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import structure_earthwork_rows # ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게 @@ -121,6 +122,9 @@ def build_handoff( rows, misses = _haul_rows(haul_table, table) work_items.extend(rows) unmatched.extend(misses) + # 사토를 실어 내는 줄 — 유토곡선이 사토를 내는데 **운반 줄이 없었다**(2026-09-08). + # 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다. + work_items.extend(spoil_haul_rows(haul_table, table)) if unit_quantity_table: rows, misses = _structure_rows(unit_quantity_table, table) work_items.extend(rows) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py new file mode 100644 index 00000000..0a4087d7 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py @@ -0,0 +1,81 @@ +"""사토 — 실어 내는 줄 (2026-09-08). + +⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 `blocks[].bands` + 와 `transfers` 에서만 만들어지는데, 사토는 `residuals(kind="spoil")` 로 남아 **어느 쪽에도 + 없다.** 그래서 구조물 잔토를 사토에 얹어도(126.63㎥) **덤프 물량이 하나도 안 늘었다** — + 채집석 공제도 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**. + +실무에는 서는 줄이다 — 울진 대흥 1공구 토적집계 `D32 사토 1,281㎥`. + +⚠ **거리는 품셈이 정하지 않는다.** 사토장까지 거리는 설계 입력(`spoil_site_distance_m`)이고, + 안 정했으면 **막고 사유를 낸다** — 임의 거리를 넣으면 그대로 금액이 된다. +⚠ **지반 갈래를 지어내지 않는다.** 유토곡선이 사토 잔량에 `ea/rr/br` 안분을 안 한다 + (실어 내는 흙이 어느 지반에서 나왔는지 모름). 갈래 없이 한 줄로 세우고 그 사실을 적는다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_INPUT_MISSING, + ORIGIN_HAUL, + WorkItemMapping, +) + +SPOIL_NAME = "사토 운반" +SPOIL_EQUIPMENT = "dump_truck" +DISTANCE_MISSING = ( + "사토장까지 운반거리가 저장에 없어 값이 서지 않음 — 품셈이 정하는 값이 아니라 설계 입력임" + "(임의 거리를 넣으면 그대로 금액이 됨)" +) +GROUND_UNKNOWN = "⚠ 사토의 지반 갈래는 유토곡선이 안분하지 않아 갈래 없이 한 줄로 섬" + + +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 "" + return [ + { + "work_item_code": code, + "name": SPOIL_NAME, + "spec": f"{float(distance):g}m" if not blocked else "", + "unit": "㎥", + "quantity": volume, + "quantity_gross": spoil.get("volume_gross_m3"), + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "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, GROUND_UNKNOWN) if part), + "composite_parts": None, + "structure_kind": None, + "blocked_kind": BLOCKED_INPUT_MISSING if blocked else None, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "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, + } + ] diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 442ae047..9e23aa45 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -83,6 +83,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings, project_root = await _project_settings(project_id) plan = await _stored_haul_plan(project_id, route_id) haul = build_haul_table(plan) + # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). + # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. + # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. + haul["spoil"] = _spoil_of(plan, settings) # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. table["pipe_lengths"] = [ @@ -150,6 +154,34 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: return JSONResponse(content=table) +def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]: + """사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다. + + ⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤). + 여기서 또 만지면 두 번 셈이 된다. + ⚠ 자연방토(`natural_spoil_m3`)는 실어 내지 않는 몫이라 **뺀다**. + """ + haul_plan = (plan or {}).get("haul_plan") if isinstance(plan, dict) else None + source = haul_plan if isinstance(haul_plan, dict) else (plan or {}) + total = float(source.get("spoil_m3") or 0.0) + natural = float(source.get("natural_spoil_m3") or 0.0) + volume = max(total - natural, 0.0) + note_parts = [f"사토 {total:,.2f}㎥"] + if natural > 0: + note_parts.append(f"자연방토 {natural:,.2f}㎥ 뺀 값") + added = source.get("structure_spoil_added_m3") + if added: + note_parts.append(f"구조물 잔토 {float(added):,.2f}㎥ 얹힌 뒤") + deducted = source.get("collected_stone_deducted_m3") + if deducted: + note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤") + return { + "volume_m3": round(volume, 3), + "distance_m": settings.get("spoil_site_distance_m"), + "note": " · ".join(note_parts), + } + + async def _route_structures(project_id: UUID) -> list[dict[str, Any]]: """배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록.""" try: @@ -219,11 +251,13 @@ class QuantitySettingsBody(BaseModel): ancillary_counts: dict[str, float] | None = None # 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①). bench_cut_depth_m: float | None = None + # 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다. + spoil_site_distance_m: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. #: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. -NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m") +NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m", "spoil_site_distance_m") @router.put("/{project_id}/quantity/settings") diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 921d9d8e..2f744508 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -115,6 +115,9 @@ def default_settings() -> dict[str, Any]: # ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음). # 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다. "bench_cut_depth_m": None, + # 사토장까지 운반거리(m) — 설계 입력이다. 품셈은 거리를 정하지 않는다. + # ⚠ `None` 은 「안 정함」이고, 그러면 사토 운반 줄이 **막힌 채로** 선다. + "spoil_site_distance_m": None, "dataset_versions": {}, }, "estimation": {