Files
Aislo/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py
T
eomsangdonandClaude Opus 5 99248440c6 feat(B08): 기초잡석 물량·용수 축 (확정 3차 ②④) — 옹벽 묶음 다섯 조각이 다 참
기초잡석(②)
- 두께 0.2m(사용자 확정). 폭은 버림 폭과 같아(KCS 34 50 05) **버림 × 두께 비**로 나옴
  ⇒ 관측 원단위로 오는 옹벽에도 값이 섬(버림 0.15㎥/m ⇒ 잡석 0.30㎥/m)
- destination `unit_price` — 운반·부설·다짐 품이 붙는 공종이라 자재총괄에 안 섞임
- 인계에 FP-12-25 줄로 섬. 묶음 구조물(옹벽)은 조각이 이미 세므로 건너뜀
- 두께는 설정 `rubble_base_thickness_m` 로 화면에서 바꿀 수 있음

용수 축(④)
- 품셈 9-13 의 18구분을 토질 × 육상/용수 × 심도로 골라 자식 코드로 내려감
- ⚠ 기본 「육상」은 **통상값이고 사용자 확정이 아님** — 그 사실을 줄 사유에 적음
- 3m 초과 칸은 원문에 없어 상위 코드 + 사유

- 설정 신설: rubble_base_thickness_m(0.2) · structure_trench_water(육상)
- tmp/tests 신설 test_b08_rubble_base.py(7건) + 터파기 시험 6건 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 06:21:05 +09:00

295 lines
12 KiB
Python

"""구조물 터파기·되메우기·잔토 인계 줄 (2026-09-08).
⚠⚠ **빠뜨렸던 자리다.** 구조물 전개는 이 셋을 `destination: earthwork` 로 내는데,
토공집계표는 **토적표만** 읽어 만들어져 그 성분을 아무도 받지 않았다. 그래서 두께 식·
기초 몫을 아무리 맞춰도 **내역서에 한 줄도 안 나갔다**(2026-09-08 B09 매김에서 드러남).
실무 내역에는 서는 줄이다 — 울진 대흥 1공구 토적집계 D12~D14:
`구조물터파기 토사 1,248 / 암 30 ㎥` · `되메우기 739 ㎥`
⚠ **품셈 9-13 은 18구분이다**(토질 3 × 육상/용수 × 심도 3). 우리는 **심도만** 안다
(터파기 깊이 = 직고 + 기초 깊이). 토질·용수는 저장 제원에 칸이 없으므로 **지어내지 않고**
상위 코드로 세운 뒤 사유를 붙인다 — 심도 갈래는 미리 갈라 두어 칸이 생기는 날 바로 붙는다.
⚠ **잔토는 내역 줄로 세우지 않는다** — 사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지
않는다. 다만 **지금 그 통로가 없다**(채집석 공제만 있다). 값을 버리지 않고 `in_bill=False`
로 넘기며 그 사실을 사유에 적는다 — 빼 버리면 빠진 줄을 아무도 못 찾는다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_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)이 세야 겹치지 않음 — ⚠ 지금 그 통로가 없어"
" 어디에도 안 실림. 값을 버리지 않고 사유와 함께 넘김"
)
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
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
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="구조물 전개 합",
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="구조물 전개 합 (터파기 − 되메우기)",
quantity=spoil,
in_bill=False,
in_bill_reason=SPOIL_REASON,
)
)
return rows, unmatched