diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index fcc33a95..7b81e5c9 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -119,6 +119,17 @@ class SummaryInput: subgrade_compaction_enabled: bool = False # 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ). face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict) + # 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①). + borrow_m3: float = 0.0 + borrow_sites: list[dict[str, Any]] = field(default_factory=list) + + +#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」). +BORROW_NAME = "토취(반입토)" +BORROW_REASON = ( + "유토곡선이 성토 부족분으로 낸 수량(다짐상태) — 토취장 거리·재료 미정이라 금액이 서지 않음" + " · 반입 재료를 몰라 자연상태로 되돌리지 않음" +) #: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」. @@ -191,6 +202,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: ) ) + if source.borrow_m3 > 0: + sites = " · ".join( + f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}㎥" + for s in source.borrow_sites + ) + note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON + rows.append( + SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False) + ) + # ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ──────── rows.extend(_haul_rows(source)) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index c18ff976..8d85db66 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -146,10 +146,16 @@ BLOCKED_UNCONFIRMED = "unconfirmed" #: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다 #: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가 #: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다. +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + BORROW_NAME, +) from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS, ) +#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①). +SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME}) + SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 29d792d5..d812bd70 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -28,6 +28,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( ORIGIN_STRUCTURE, SLOPE_GROUPS, SUBTOTAL_GROUPS, + SUMMARY_ONLY_GROUPS, WorkItemMapping, composite_quantities, masonry_class, @@ -164,7 +165,6 @@ def _earthwork_rows( template_ready = all(inputs.get(name) for name in needs) if template and template_ready: variant_value = template.format(**inputs) - # 부모(갈래 고르기형)를 가리키는 매핑 — 설정값으로 잎 코드를 고름(초류종자살포 5-24 · 09-14 ㉮). leaf_from = str((entry or {}).get("leaf_from") or "") leaf_codes = (entry or {}).get("leaf_codes") or {} leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None @@ -174,7 +174,9 @@ def _earthwork_rows( if code and missing and (entry or {}).get("variant_missing_reason"): blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING blocked_reason = blocked_reason or str(entry["variant_missing_reason"]) - if code is None and not is_subtotal: + if group in SUMMARY_ONLY_GROUPS: # 토취 — 코드 없이 수량만 · 사유는 집계 비고(브레인 ①) + blocked_kind, blocked_reason = BLOCKED_INPUT_MISSING, str(row.get("note") or "") + elif code is None and not is_subtotal: label = f"{group}({ground})" if ground else group unmatched.append(f"{label} — {method_note}" if method_note else label) if blocked_kind is None: diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py index bd8e2cbe..65926c01 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -266,6 +266,22 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: ] +def borrow_of(plan: dict[str, Any] | None) -> dict[str, Any] | None: + """토취(반입토) — 유토곡선 잔량의 부족분과 구간(2026-09-14 브레인 ①). 없으면 `None`. + + ⚠ **다짐상태** 그대로 — 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음). + """ + volume = float((plan or {}).get("borrow_m3") or 0.0) + if volume <= 0: + return None + sites = [ + {"from_m": r.get("from_m"), "to_m": r.get("to_m"), "volume_m3": float(r["volume_m3"])} + for r in (plan or {}).get("residuals") or [] + if r.get("kind") == "borrow" and float(r.get("volume_m3") or 0.0) > 0 + ] + return {"volume_m3": volume, "volume_basis": "compacted", "sites": sites} + + @dataclass(slots=True) class HaulCheck: """검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡).""" diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 842db03c..c79c0609 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -43,8 +43,16 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as bui from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table -from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows +from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( + borrow_of, + check_against_plan, + summary_input_rows, +) from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split +from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS +from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import ( + recompute_haul_plan as _recompute_haul_plan, +) from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes @@ -126,6 +134,7 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) + haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①) # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. table["pipe_lengths"] = [ @@ -158,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: earthwork_totals=table.get("totals") or {}, slope_totals=slope.get("totals") or {}, haul_rows=summary_input_rows(haul), + borrow_m3=(haul["borrow"] or {}).get("volume_m3") or 0.0, + borrow_sites=(haul["borrow"] or {}).get("sites") or [], rock_classes=rock_classes(settings), rock_ratios_pct=settings.get("rock_ratios_pct") or {}, application_ratios={ @@ -626,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - for name, method in values["rock_methods"].items() if method in ROCK_METHODS } + before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS} try: # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). @@ -653,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - status_code=500, content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, ) - return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}}) + after = quantity_settings(root) + changed = any(before[key] != after.get(key) for key in HAUL_PLAN_KEYS) + recomputed = await _recompute_haul_plan(project_id) if changed else False + return JSONResponse( + content={ + "status": "success", + "quantity": saved.get("quantity") or {}, + "haul_plan_recomputed": recomputed, + } + ) def _save_quantity( diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork_HaulPlan.py b/B08_Quantity/B08_Quantity_Router_Earthwork_HaulPlan.py new file mode 100644 index 00000000..e392c850 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Earthwork_HaulPlan.py @@ -0,0 +1,40 @@ +"""산출 조건 [저장] 뒤 **운반계획 다시 세우기** — 2026-09-14 브레인 ②(지침 5장). + +구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획)의 밑수다. B08 [저장]이 계획을 다시 안 세우면 +B06 을 다시 저장하기 전까지 토적표(새 계수) ↔ 운반표(옛 계획)가 갈린다(계수 칸은 전부터 같은 틈). +⇒ 그 칸이 **바뀐 저장**만 B06 [저장]과 같은 서버 재계산을 부른다(무거워서 다른 칸 저장은 안 부름). +본 라우터(`B08_Quantity_Router_Earthwork.py`)가 700줄에 닿아 이 파일로 뗌. +""" + +from __future__ import annotations + +import logging +from uuid import UUID + +from B06_Section.B06_Section_Repository import get_workflow_route_context +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) + +#: 운반계획의 밑수가 되는 산출 조건 칸. +HAUL_PLAN_KEYS = ( + "rock_class_set", + "rock_classes", + "rock_ratios_pct", + "rock_methods", + "conversion_factors_override", + "dozer_haul_limit_m", +) + + +async def recompute_haul_plan(project_id: UUID) -> bool: + """B06 [저장]과 같은 서버 재계산 — 못 하면 저장은 살리고 거짓(화면이 옛 계획임을 앎).""" + from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + + try: + context = await run_with_connection(get_workflow_route_context, project_id) + route_id = int((context or {}).get("route_id") or 0) + return bool(route_id) and await recompute_server_side(project_id, route_id) >= 0 + except Exception: + logger.exception("B08 저장 뒤 운반계획 재계산 실패: project_id=%s", project_id) + return False diff --git a/resources/tester/test_b08_borrow_row.py b/resources/tester/test_b08_borrow_row.py new file mode 100644 index 00000000..be863e48 --- /dev/null +++ b/resources/tester/test_b08_borrow_row.py @@ -0,0 +1,64 @@ +"""토취(반입토) 줄 — 2026-09-14 브레인 ① 승인(수량만 · 금액은 사유). + +B06 유토곡선이 성토 부족분을 토취로 냄(936be972 `haul_plan.borrow_m3` 7,259.84㎥ · 두 구간)인데 B08 이 어느 탭·인계에도 +안 옮겨 수량이 통째로 빠졌음. ⇒ 운반표에 싣고 · 토공집계에 줄 · 인계에 막힌 줄(「토취장 거리·재료 미정」). +⚠ 수량은 유토곡선이 쌓은 **다짐상태** 그대로 — 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + BORROW_NAME, + SummaryInput, + build_rows, +) +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_HaulSummary import borrow_of # noqa: E402 + +PLAN = { + "borrow_m3": 7259.84, + "residuals": [ + {"kind": "borrow", "volume_m3": 3494.69, "from_m": 246.54, "to_m": 529.33}, + {"kind": "borrow", "volume_m3": 3765.15, "from_m": 720, "to_m": 1078.01}, + {"kind": "spoil", "volume_m3": 4.57, "from_m": 205, "to_m": 205}, + ], +} + + +def test_운반계획의_토취를_구간과_함께_싣는다() -> None: + borrow = borrow_of(PLAN) + assert borrow["volume_m3"] == pytest.approx(7259.84) and borrow["volume_basis"] == "compacted" + assert [s["volume_m3"] for s in borrow["sites"]] == [3494.69, 3765.15] + assert borrow_of({"borrow_m3": 0}) is None and borrow_of(None) is None + + +def test_토공집계에_수량만_선다() -> None: + rows = [r for r in build_rows(SummaryInput(borrow_m3=7259.84)) if r.group == BORROW_NAME] + assert len(rows) == 1 and rows[0].amount == pytest.approx(7259.84) + assert rows[0].in_bill is False and "토취장 거리·재료 미정" in rows[0].note + + +def test_인계는_막힌_줄_하나_금액_없음() -> None: + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table + + borrow = borrow_of(PLAN) + summary = build_table(SummaryInput(borrow_m3=borrow["volume_m3"], borrow_sites=borrow["sites"])) + handoff = build_handoff(summary_table=summary, haul_table={"rows": [], "borrow": borrow}) + rows = [r for r in handoff["work_items"] if r["name"] == BORROW_NAME] + assert len(rows) == 1, rows # 한 줄만 + row = rows[0] + assert row["quantity"] == pytest.approx(7259.84) and row["in_bill"] is False + assert ( + row["blocked_kind"] == "input_missing" and "토취장 거리·재료 미정" in row["blocked_reason"] + ) + assert "246.54~529.33m 3,494.69㎥" in row["blocked_reason"] + assert not any(BORROW_NAME in str(item) for item in handoff["unmatched_work_items"]) diff --git a/resources/tester/test_b08_settings_recompute_haul.py b/resources/tester/test_b08_settings_recompute_haul.py new file mode 100644 index 00000000..0975d9e9 --- /dev/null +++ b/resources/tester/test_b08_settings_recompute_haul.py @@ -0,0 +1,64 @@ +"""산출 조건 [저장]이 운반계획을 다시 세움 — 2026-09-14 브레인 ②(지침 5장 「[저장]·[확정]은 서버가 정본으로 다시 계산」). + +구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획)의 밑수인데 B08 [저장]이 운반계획을 다시 안 셈 +→ B06 을 다시 저장하기 전까지 토적표(새 계수) ↔ 운반표(옛 계획)가 갈렸음(계수 칸은 전부터 같은 틈). +⇒ 그 칸이 **바뀐 저장**만 서버 재계산(`recompute_server_side`)을 부름 · 다른 칸 저장은 안 부름(무거움). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Router_Earthwork as earthwork_router # noqa: E402 + +PROJECT_ID = "56565656-5656-5656-5656-565656565656" +URL = f"/api/projects/{PROJECT_ID}/quantity/settings" + + +@pytest.fixture() +def calls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[TestClient, list]: + recorded: list = [] + + async def fake_run(func, *args): + return "project" + + async def fake_recompute(project_id): + recorded.append(str(project_id)) + return True + + monkeypatch.setattr(earthwork_router, "run_with_connection", fake_run) + monkeypatch.setattr(earthwork_router, "resolve_stored_project_path", lambda _p: str(tmp_path)) + monkeypatch.setattr(earthwork_router, "_recompute_haul_plan", fake_recompute) + app = FastAPI() + app.include_router(earthwork_router.router) + return TestClient(app), recorded + + +def test_운반계획_밑수가_바뀐_저장만_재계산(calls) -> None: + client, recorded = calls + ratios = {"rock_ratios_pct": {"연암": 60, "보통암": 40}} + first = client.put(URL, json=ratios) + assert first.status_code == 200 and first.json()["haul_plan_recomputed"] is True + assert recorded == [PROJECT_ID] + # 같은 값을 다시 저장 — 안 부름 + assert client.put(URL, json=ratios).json()["haul_plan_recomputed"] is False + # 운반계획과 무관한 칸 — 안 부름 + assert ( + client.put(URL, json={"topsoil_thickness_m": 0.2}).json()["haul_plan_recomputed"] is False + ) + assert len(recorded) == 1 + for body in ( + {"rock_methods": {"연암": "ripping"}}, + {"conversion_factors_override": {"soil": {"compacted": 0.85}}}, + {"dozer_haul_limit_m": 70}, + ): + assert client.put(URL, json=body).json()["haul_plan_recomputed"] is True, body + assert len(recorded) == 4