"""구조물 터파기·되메우기·잔토 인계 줄 (2026-09-08). ⚠⚠ **빠뜨렸던 자리다.** 구조물 전개는 이 셋을 `destination: earthwork` 로 내는데, 토공집계표는 **토적표만** 읽어 만들어져 그 성분을 아무도 받지 않았다. 그래서 두께 식· 기초 몫을 아무리 맞춰도 **내역서에 한 줄도 안 나갔다**(2026-09-08 B09 매김에서 드러남). 실무 내역에는 서는 줄이다 — 울진 대흥 1공구 토적집계 D12~D14: `구조물터파기 토사 1,248 / 암 30 ㎥` · `되메우기 739 ㎥` ⚠ **품셈 9-13 은 18구분이다**(토질 3 × 육상/용수 × 심도 3). 세 축이 다 서면 자식 코드로 내려간다 — 토질은 측점 설계값(`design.ground_type`), 심도는 구조물 제원(직고 + 기초 깊이), 용수는 산출 조건 칸(기본 「육상」은 **통상값**이고 사용자 확정이 아니다). 하나라도 없으면 **지어내지 않고** 상위 코드로 세운 뒤 사유를 붙인다. ⚠ **잔토는 내역 줄로 세우지 않는다** — 사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지 않는다. **그 통로는 2026-09-09 에 생겼다**(`Engine_HaulInputs` → 유토곡선 사토 가산 → 「사토 운반」 줄). 여기서는 값과 사유만 넘기고 `in_bill=False` 로 둔다 — 빼 버리면 빠진 줄을 아무도 못 찾는다. """ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, ORIGIN_EARTHWORK, ORIGIN_STRUCTURE, WorkItemMapping, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import GROUND_TYPE_LABEL from common_util.common_util_excavation import ( WALL_BLINDING_DEPTH_M, WALL_FOUNDATION_DEPTH_M, ) #: 품셈 9-13 심도 갈래 — 원문 표기(`0~1m · 1~2m · 2~3m`)를 그대로 쓴다. DEPTH_BANDS: tuple[tuple[float, str], ...] = ((1.0, "0~1m"), (2.0, "1~2m"), (3.0, "2~3m")) DEPTH_OVER = "3m 초과" TRENCH_GROUP = "구조물터파기" RUBBLE_GROUP = "기초잡석" BACKFILL_GROUP = "되메우기" SPOIL_GROUP = "잔토처리" #: ⚠ 남은 축은 **용수 하나**다(2026-09-08). 토질은 측점 설계값(`design.ground_type`)에서 #: 끌어오고, 심도는 구조물 제원(직고 + 기초 깊이)에서 나온다. 용수는 저장에 칸이 없어 #: **입력 칸이 서야 하는 자리**다 — 모른다고 「육상」으로 눅이지 않는다. WATER_BLOCKED_REASON = ( "용수 유무가 저장에 없어 품셈 9-13 의 18구분 중 육상·용수 어느 쪽인지 못 고름" " — 모른다고 육상으로 눅이지 않음" ) GROUND_BLOCKED_REASON = "토질을 못 가름" #: 품셈 9-13 의 18구분 — **토질 3 × 육상/용수 × 심도 3**. 코드 차례가 원문 그대로다 #: (육상토사 0~1·1~2·2~3 → 용수토사 셋 → 육상 암절취 셋 → … → 용수 발파암 셋). GROUND_ORDER = ("soil", "ripping_rock", "blasting_rock") WATER_ORDER = ("육상", "용수") DEPTH_ORDER = ("0~1m", "1~2m", "2~3m") #: ⚠ 3m 를 넘는 칸이 **원문에 없다** — 지어내지 않고 상위 코드로 두고 사유를 낸다. DEPTH_OVER_REASON = "심도 3m 를 넘는 칸이 품셈 9-13 원문에 없음 — 상위 코드로 둠" #: ⚠ **사용자 확정이 아니라 통상값**이다(2026-09-09 확정 3차 ④). 화면에도 그 사실이 뜨고 #: 사용자가 「용수」로 뒤집으면 코드가 한 칸 옮겨 간다. WATER_DEFAULT_NOTE = "용수 유무 — 「육상」은 통상값이고 사용자 확정이 아님(확정 3차 ④)" def trench_child_code(parent: str | None, ground: str | None, water: str, band: str) -> str | None: """품셈 9-13 의 18구분 중 한 칸. 축이 하나라도 없으면 `None`(상위 코드로 둔다).""" if not parent or ground not in GROUND_ORDER or water not in WATER_ORDER: return None if band not in DEPTH_ORDER: return None index = ( GROUND_ORDER.index(ground) * len(WATER_ORDER) * len(DEPTH_ORDER) + WATER_ORDER.index(water) * len(DEPTH_ORDER) + DEPTH_ORDER.index(band) ) return f"{parent}-{index + 1:02d}" SPOIL_REASON = ( "사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지 않음 — 값을 버리지 않고 사유와" " 함께 넘김(유토곡선이 사토에 얹어 「사토 운반」 줄로 섬)" ) #: ⚠ **값 옆에 두는 사실** — 코드 주석에만 두면 화면에서 안 보인다(오늘 여러 번 나온 자리). #: 되메우기·잔토는 **제자리 기하 부피**로 셈한다. 엄밀히는 되메움에 **쓰이는 흙**이 #: 다짐부피 ÷ C 라 조금 더 들고 그만큼 잔토가 줄어야 한다. **실무 정본도 그냥 빼므로** #: (터파기 1.55 − 되메우기 0.30 = 잔토 1.25) 값은 그대로 두고 **사실만 적어 둔다** — #: 「잔토가 조금 많다」가 나중에 올라오면 이 문장이 답이다. STATE_NOTE = ( "제자리 기하 부피(자연상태) — ⚠ 되메움에 쓰이는 흙은 다짐부피 ÷ C 라 조금 더 들지만" " 실무 정본도 그냥 빼므로 그대로 둠" ) def _depth_band(depth_m: float) -> str: for limit, label in DEPTH_BANDS: if depth_m <= limit: return label return DEPTH_OVER def _foundation_depth(options: dict[str, Any]) -> float: """기초 깊이 — 「기초유」 0.5 · 「기초버림」 0.1 · 안 고르면 0(비탈분만).""" value = str(options.get("foundation") or "").strip() if value == "기초유": return WALL_FOUNDATION_DEPTH_M if value == "기초버림": return WALL_BLINDING_DEPTH_M return 0.0 def _row(**fields: Any) -> dict[str, Any]: """줄 한 벌 — 계약이 요구하는 칸을 **모두** 채운다(빌더마다 같은 모양이라야 한다).""" row: dict[str, Any] = { "work_item_code": None, "name": "", "spec": "", "unit": "㎥", "quantity": 0.0, "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, "station_from": None, "station_to": None, "excavation_method": None, "spec_detail": "", "composite_parts": None, "structure_kind": None, "blocked_kind": None, "blocked_reason": "", "variant_axis": None, "variant_value": None, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", "composite_not_ready": None, "in_bill": True, "in_bill_reason": "", "origin": ORIGIN_EARTHWORK, } row.update(fields) return row def rubble_base_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: """기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다. ⚠ **묶음으로 서는 구조물은 건너뛴다** — 옹벽 묶음에 이미 `FP-12-25` 조각이 있어 여기서 또 세우면 같은 잡석을 두 번 센다(타설 줄에서 이미 겪은 자리). """ total = 0.0 bases: list[str] = [] for structure in unit_quantity_table.get("structures") or []: if mapping.composite_for(str(structure.get("type_id") or "")): continue for component in structure.get("components") or []: if str(component.get("name") or "") != RUBBLE_GROUP: continue amount = float(component.get("amount") or 0.0) if amount <= 0: continue total += amount basis = str(component.get("basis") or "") if basis and basis not in bases: bases.append(basis) if total <= 0: return [], [] entry = mapping.for_earthwork(RUBBLE_GROUP, None) code = (entry or {}).get("work_item_code") return [ _row( work_item_code=code, name=RUBBLE_GROUP, spec="구조물", spec_detail=bases[0] if bases else "구조물 전개 합", quantity=round(total, 3), origin=ORIGIN_STRUCTURE, in_bill=code is not None, in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다", ) ], ([] if code else [RUBBLE_GROUP]) def structure_earthwork_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping, water: str | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: """구조물이 낸 터파기·되메우기·잔토를 **공종 축**으로 올린다. `water` 는 「육상」·「용수」. 안 주면 「안 정함」이라 상위 코드로 두고 사유를 낸다. """ # ⚠ 키는 **(심도, 토질)** 뿐이다 — 근거 문구까지 키에 넣으면 같은 「암절취 · 2~3m」이 # 측점 문구가 다르다는 이유로 **두 줄로 갈린다**(2026-09-08 실화면에서 그랬다). trench: dict[tuple[str, str | None], float] = {} bases: dict[tuple[str, str | None], list[str]] = {} backfill = 0.0 spoil = 0.0 # ⚠ 터파기 성분이 아예 없는 구조물을 적어 둔다 — **줄이 안 서는 것보다 나쁜 것이 # 「빠진 줄조차 안 보이는 것」**이다(2026-09-09 감사: 옹벽이 터파기 0 줄이었고 # 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다. without_trench: list[str] = [] for structure in unit_quantity_table.get("structures") or []: options = structure.get("options") or {} height = float(structure.get("height_m") or options.get("height_m") or 0.0) band = _depth_band(height + _foundation_depth(options)) ground = structure.get("ground_type") or None ground_basis = str(structure.get("ground_type_basis") or "") for component in structure.get("components") or []: if str(component.get("destination") or "") != "earthwork": continue name = str(component.get("name") or "") amount = float(component.get("amount") or 0.0) if amount <= 0: continue if name == "터파기": key = (band, ground) trench[key] = trench.get(key, 0.0) + amount reasons = bases.setdefault(key, []) if ground_basis and ground_basis not in reasons: reasons.append(ground_basis) elif name == "되메우기": backfill += amount elif name == "잔토처리": spoil += amount if not any( str(component.get("name") or "") == "터파기" and float(component.get("amount") or 0.0) > 0 for component in structure.get("components") or [] ): without_trench.append( str(structure.get("name") or structure.get("type_id") or "이름 없는 구조물") ) rows: list[dict[str, Any]] = [] unmatched: list[str] = [] if trench: entry = mapping.for_earthwork(TRENCH_GROUP, None) code = (entry or {}).get("work_item_code") if code is None: unmatched.append(TRENCH_GROUP) order = [band for _, band in DEPTH_BANDS] + [DEPTH_OVER] for (band, ground), amount in sorted( trench.items(), key=lambda item: (order.index(item[0][0]), str(item[0][1] or "")) ): if not amount: continue ground_basis = " · ".join(bases.get((band, ground)) or []) label = GROUND_TYPE_LABEL.get(str(ground), str(ground)) if ground else None child = trench_child_code(code, ground, str(water or ""), band) if not ground: reason = f"{GROUND_BLOCKED_REASON} — {ground_basis}" elif not water: reason = WATER_BLOCKED_REASON elif child is None: reason = DEPTH_OVER_REASON else: reason = "" detail = " · ".join( part for part in ( "구조물 전개 합", ground_basis, WATER_DEFAULT_NOTE if water == "육상" else "", ) if part ) spec = " · ".join(part for part in (label or "", water or "", f"심도 {band}") if part) rows.append( _row( work_item_code=child or code, name=TRENCH_GROUP, spec=spec, ground_class=label, spec_detail=detail, quantity=amount, blocked_kind=BLOCKED_INPUT_MISSING if reason else None, blocked_reason=reason, in_bill=not reason, in_bill_reason=reason, ) ) if backfill > 0: entry = mapping.for_earthwork(BACKFILL_GROUP, None) code = (entry or {}).get("work_item_code") if code is None: unmatched.append(BACKFILL_GROUP) rows.append( _row( work_item_code=code, name=BACKFILL_GROUP, spec="구조물", spec_detail=f"구조물 전개 합 · {STATE_NOTE}", quantity=backfill, in_bill=code is not None, in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다", ) ) if spoil > 0: rows.append( _row( name=SPOIL_GROUP, spec="구조물", spec_detail=f"구조물 전개 합 (터파기 − 되메우기) · {STATE_NOTE}", quantity=spoil, in_bill=False, in_bill_reason=SPOIL_REASON, ) ) if without_trench: # 값을 지어내지 않고 **빠졌다는 사실만** 낸다 — 관측 원단위(옹벽·집수정 등)는 표에 # 터파기 줄이 있는 것도 있고 없는 것도 있어, 없으면 그 구조물만 조용히 빠진다. rows.append( _row( name=TRENCH_GROUP, spec="구조물", spec_detail=( "⚠ 터파기가 안 선 구조물: " + " · ".join(sorted(set(without_trench))) + " — 관측 원단위 표에 터파기 줄이 없음(전개식으로 가는 종류는 높이·두께에서" " 나오지만 관측표는 표에 있는 줄만 냄). **값을 지어내지 않음** —" " 실무 내역서에서 그 구조물의 터파기를 어떻게 잡는지 확인할 것" ), quantity=0.0, in_bill=False, in_bill_reason="터파기 물량을 못 세움 — 관측 원단위에 그 줄이 없음", blocked_kind=BLOCKED_UNIT_DATA_MISSING, blocked_reason="관측 원단위 표에 터파기 줄이 없음", ) ) return rows, unmatched