feat(B08): 구조물터파기 토질을 측점 설계값에서 끌어옴 — 남은 축은 용수 하나

- 새 저장 칸을 만들지 않음. 측점마다 이미 있는 `design.ground_type`
  (soil·ripping_rock·blasting_rock)이 품셈 9-13 토질 3구분과 그대로 맞물림
- 판정 규칙: 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 냄.
  섞이면 다수결로 고르지 않고 갈래별 측점 수를 사유에 적음(암 단가가 몇 배라
  임의 선택이 금액으로 굳음). 걸친 측점이 없으면 가장 가까운 측점 + 그 사실을 근거에
- 터파기 줄이 토질·심도로 갈려 서고, 막힌 사유가 「용수 유무」 하나로 좁혀짐
- tmp/tests 2건 추가(측점값 반영·섞임 판정)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:33:17 +09:00
co-authored by Claude Opus 5
parent d7d76e4e15
commit b4ae32d1be
3 changed files with 130 additions and 18 deletions
@@ -342,6 +342,10 @@ class StructureQuantity:
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거.
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
@@ -1097,6 +1101,68 @@ def section_modes_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float,
return modes
#: 저장된 지반 갈래 ↔ 품셈 9-13 토질 3구분. **새 칸을 만들지 않는다** — 측점마다 이미
#: `design.ground_type` 이 저장돼 있고(재생성 사고 때 이 값이 비어 B08 이 통째로 0 이 됐던
#: 그 키다), 값 셋이 품셈 구분과 그대로 맞물린다(2026-09-08 조율 창 확인).
GROUND_TYPE_LABEL = {
"soil": "토사",
"ripping_rock": "암절취",
"blasting_rock": "발파암",
}
def ground_types_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]:
"""저장된 횡단 설계 목록 → `{측점: 지반갈래}`. 값이 없는 측점은 담지 않는다."""
grounds: dict[float, str] = {}
for item in designs or ():
design = item.get("design") if isinstance(item, dict) else None
ground = str((design or {}).get("ground_type") or "").strip()
if not ground:
continue
grounds[float(_num(item.get("chainage_m")))] = ground
return grounds
def ground_type_at(
structure: dict[str, Any], ground_types: dict[float, str] | None
) -> tuple[str | None, str]:
"""(토질, 근거). 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 낸다.
⚠ **판정 규칙 — 섞이면 안 고른다.** 구조물은 구간(start~end)이고 지반은 측점 값이라
한 구조물이 토사 측점과 암 측점에 걸칠 수 있다. 그때 다수결로 한쪽을 고르면 **임의값이
금액으로 굳는다**(암 단가가 몇 배다). 섞였다는 사실과 갈래별 측점 수를 근거에 적고
값은 `None` 으로 둔다 — 성절토·용수에서 지킨 그대로다.
⚠ 걸친 측점이 하나도 없으면(구간이 측점 사이에 통째로 들어간 짧은 구조물) **가장 가까운
측점**을 쓴다 — 그 사실도 근거에 적는다.
"""
if not ground_types:
return None, "측점 지반 갈래가 저장에 없어 못 가름"
start, end = _num(structure.get("start_m")), _num(structure.get("end_m"))
if end < start:
start, end = end, start
inside = {
chainage: kind for chainage, kind in ground_types.items() if start <= float(chainage) <= end
}
if not inside:
center = (start + end) / 2.0
nearest = min(ground_types, key=lambda chainage: abs(float(chainage) - center))
kind = ground_types[nearest]
return (
kind,
f"걸친 측점이 없어 가장 가까운 측점({nearest:g}m)의 {GROUND_TYPE_LABEL.get(kind, kind)}",
)
counts: dict[str, int] = {}
for kind in inside.values():
counts[kind] = counts.get(kind, 0) + 1
if len(counts) == 1:
kind = next(iter(counts))
return kind, f"걸친 측점 {len(inside)}곳이 모두 {GROUND_TYPE_LABEL.get(kind, kind)}"
breakdown = " · ".join(
f"{GROUND_TYPE_LABEL.get(kind, kind)} {count}곳" for kind, count in sorted(counts.items())
)
return None, f"걸친 측점의 지반이 섞여 못 가름 — {breakdown}"
def _section_mode_at(
structure: dict[str, Any], section_modes: dict[float, str] | None
) -> str | None:
@@ -1120,6 +1186,7 @@ def build_table(
structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None,
section_modes: dict[float, str] | None = None,
ground_types: dict[float, str] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
observed = load_observed_table()
@@ -1128,10 +1195,11 @@ def build_table(
for item in structures:
expanded_inputs.append(item)
expanded_inputs.extend(attachments_of(item))
quantities = [
expand(item, names, observed, _section_mode_at(item, section_modes))
for item in expanded_inputs
]
quantities = []
for item in expanded_inputs:
quantity = expand(item, names, observed, _section_mode_at(item, section_modes))
quantity.ground_type, quantity.ground_type_basis = ground_type_at(item, ground_types)
quantities.append(quantity)
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
@@ -1163,6 +1231,10 @@ def build_table(
"billing_quantity": item.billing_quantity,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
# 구조물이 놓인 자리의 지반 갈래 — **품셈 9-13 토질 3구분**이 이 값으로 갈린다.
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
"ground_type": item.ground_type,
"ground_type_basis": item.ground_type_basis,
"notes": item.notes,
"components": [
{