fix(b08): 철근 이름·규격 가르기(이형철근 + D13·D16) — 대응표 조각은 이름+규격 키·이름 두 색인으로 집고 경계 검사도 두 키로 · 관측 원단위 spec 칸을 넘김 · 전후 물량 대조 시험(가드 없이 돌리면 빨강 확인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -72,6 +72,9 @@ def verify_double_count_boundaries(
|
||||
name = str(component.get("name") or "").strip()
|
||||
destination = str(component.get("destination") or "")
|
||||
destinations.setdefault(name, set()).add(destination)
|
||||
# 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂.
|
||||
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
|
||||
destinations.setdefault(key, set()).add(destination)
|
||||
if name in EARTHWORK_ONLY and destination != "earthwork":
|
||||
found.append(
|
||||
f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — "
|
||||
|
||||
@@ -327,10 +327,18 @@ def composite_quantities(
|
||||
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
|
||||
return [], [{"code": None, "reason": note}]
|
||||
|
||||
amounts: dict[str, tuple[float, str]] = {}
|
||||
for component in structure.get("components") or []:
|
||||
# ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두
|
||||
# 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**.
|
||||
by_key: dict[str, float] = {}
|
||||
by_name: dict[str, float] = {}
|
||||
key_of: dict[int, str] = {}
|
||||
for index, component in enumerate(structure.get("components") or []):
|
||||
name = str(component.get("name") or "").strip()
|
||||
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
|
||||
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
|
||||
amount = float(component.get("amount") or 0.0)
|
||||
by_key[key] = by_key.get(key, 0.0) + amount
|
||||
by_name[name] = by_name.get(name, 0.0) + amount
|
||||
key_of[index] = key
|
||||
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
|
||||
|
||||
parts: list[dict[str, Any]] = []
|
||||
@@ -340,14 +348,14 @@ def composite_quantities(
|
||||
parts.append({"code": str(spec)})
|
||||
continue
|
||||
sources = list(spec.get("from_components") or [])
|
||||
found = [name for name in sources if name in amounts]
|
||||
total = sum(amounts[name][0] for name in found)
|
||||
found = [name for name in sources if name in by_key or name in by_name]
|
||||
total = sum(by_key[name] if name in by_key else by_name[name] for name in found)
|
||||
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
|
||||
total *= kg_to_ton
|
||||
kinds = {
|
||||
component.get("basis_kind")
|
||||
for component in structure.get("components") or []
|
||||
if str(component.get("name") or "").strip() in found
|
||||
for index, component in enumerate(structure.get("components") or [])
|
||||
if key_of[index] in found or str(component.get("name") or "").strip() in found
|
||||
}
|
||||
suffix = spec.get("kind_suffix")
|
||||
entry: dict[str, Any] = {
|
||||
|
||||
@@ -304,6 +304,8 @@ def expand_observed(
|
||||
+ (f" ({note})" if note else ""),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": source_key,
|
||||
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
|
||||
"spec": str(item.get("spec") or ""),
|
||||
}
|
||||
)
|
||||
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|
||||
|
||||
@@ -53,9 +53,8 @@ DESTINATION = {
|
||||
RUBBLE_BASE_NAME: "unit_price",
|
||||
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
|
||||
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
|
||||
# ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가
|
||||
# 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로).
|
||||
"이형철근 D13": "material",
|
||||
# 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음).
|
||||
"이형철근": "material",
|
||||
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
||||
"면목": "material",
|
||||
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
||||
|
||||
@@ -439,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
||||
0.1508,
|
||||
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
|
||||
),
|
||||
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
|
||||
("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로
|
||||
("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
|
||||
("면목", "m", 1.0, "1"),
|
||||
),
|
||||
@@ -540,8 +540,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
|
||||
per_m * length_m,
|
||||
destination,
|
||||
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
|
||||
spec="".join(spec),
|
||||
)
|
||||
for name, unit, per_m, basis in table["rows"]
|
||||
for name, unit, per_m, basis, *spec in table["rows"]
|
||||
if (destination := routed(name, notes))
|
||||
]
|
||||
if not options.get("ditch_spec"):
|
||||
|
||||
@@ -86,13 +86,15 @@
|
||||
"basis_note": "Ø50"
|
||||
},
|
||||
{
|
||||
"name": "이형철근 D13",
|
||||
"name": "이형철근",
|
||||
"spec": "D13",
|
||||
"unit": "kg",
|
||||
"amount": 13.45,
|
||||
"destination": "material"
|
||||
},
|
||||
{
|
||||
"name": "이형철근 D16",
|
||||
"name": "이형철근",
|
||||
"spec": "D16",
|
||||
"unit": "kg",
|
||||
"amount": 30.42,
|
||||
"destination": "material"
|
||||
@@ -218,7 +220,8 @@
|
||||
"basis_note": "원문 값(라이브러리 표기 21.28)"
|
||||
},
|
||||
{
|
||||
"name": "이형철근 D13",
|
||||
"name": "이형철근",
|
||||
"spec": "D13",
|
||||
"unit": "kg",
|
||||
"amount": 4.776,
|
||||
"destination": "material",
|
||||
|
||||
@@ -45,7 +45,8 @@ def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None:
|
||||
def test_드러난_넷이_제자리로_감() -> None:
|
||||
ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"})
|
||||
got = {component.name: component.destination for component in ditch}
|
||||
assert got["이형철근 D13"] == "material" and got["면목"] == "material"
|
||||
assert got["이형철근"] == "material" and got["면목"] == "material"
|
||||
assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로
|
||||
assert not [note for note in notes if "갈 곳이 표" in note]
|
||||
plain, _ = open_ditch(10.0, {})
|
||||
assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼.
|
||||
|
||||
「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어
|
||||
하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]`
|
||||
(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂)
|
||||
③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함.
|
||||
|
||||
옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏
|
||||
집수정 □형 Ø800 1개소 D13 4.776 ㎏
|
||||
L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402
|
||||
verify_double_count_boundaries,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
|
||||
|
||||
|
||||
def _구조물들() -> dict:
|
||||
return build_unit(
|
||||
[
|
||||
{
|
||||
"structure_id": "w1",
|
||||
"type_id": "retaining_wall",
|
||||
"start_m": 100.0,
|
||||
"end_m": 110.0,
|
||||
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
|
||||
},
|
||||
{
|
||||
"structure_id": "b1",
|
||||
"type_id": "pipe_inlet_basin",
|
||||
"start_m": 200.0,
|
||||
"end_m": 200.0,
|
||||
"options": {
|
||||
"inlet_basin_form": "□형(기본형)",
|
||||
"inlet_basin_material": "콘크리트",
|
||||
"pipe_diameter_mm": "800",
|
||||
},
|
||||
},
|
||||
{
|
||||
"structure_id": "d1",
|
||||
"type_id": "open_ditch",
|
||||
"start_m": 300.0,
|
||||
"end_m": 310.0,
|
||||
"options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0},
|
||||
},
|
||||
],
|
||||
{"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"},
|
||||
)
|
||||
|
||||
|
||||
def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None:
|
||||
row = next(
|
||||
r
|
||||
for r in build_handoff(unit_quantity_table=_구조물들())["work_items"]
|
||||
if r.get("composite_parts")
|
||||
)
|
||||
rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code")))
|
||||
assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready")
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind
|
||||
|
||||
wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall")
|
||||
assert structure_kind(wall) == "철근구조물"
|
||||
|
||||
|
||||
def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None:
|
||||
table = build_table(_구조물들())
|
||||
rebar = {
|
||||
row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"])
|
||||
for row in table["rows"]
|
||||
if row["supply_key"].startswith("이형철근")
|
||||
}
|
||||
assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자
|
||||
assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98)
|
||||
assert rebar["이형철근 D16"][0] == pytest.approx(304.2)
|
||||
assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근
|
||||
assert "이형철근" not in " ".join(table["missing_rate_materials"])
|
||||
|
||||
|
||||
def test_경계_검사가_철근을_여전히_집는다() -> None:
|
||||
"""철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂."""
|
||||
unit = copy.deepcopy(_구조물들())
|
||||
wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall")
|
||||
for component in wall["components"]:
|
||||
if str(component["name"]).startswith("이형철근"):
|
||||
component["destination"] = "earthwork"
|
||||
found = verify_double_count_boundaries(unit, None, load_mapping())
|
||||
assert any("이형철근" in text and "묶음 조각" in text for text in found), found
|
||||
assert not verify_double_count_boundaries(_구조물들(), None, load_mapping())
|
||||
|
||||
|
||||
def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None:
|
||||
table = build_table(_구조물들())
|
||||
rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")]
|
||||
assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")]
|
||||
unit = _구조물들()
|
||||
specs = {
|
||||
(c["name"], c.get("spec"))
|
||||
for s in unit["structures"]
|
||||
for c in s["components"]
|
||||
if str(c["name"]).startswith("이형철근")
|
||||
}
|
||||
assert specs == {("이형철근", "D13"), ("이형철근", "D16")}
|
||||
Reference in New Issue
Block a user