feat(axis): 이중계상 ④ — 양식 호표가 품은 성분을 모음 줄이 또 세면 멈춤
기초잡석 줄·콘크리트 타설 줄은 여러 구조물 성분을 이름으로 모아 한 줄로 세워 누구 것을 뺐는지 안 남김. 그래서 물량 보존으로 봄 — 모음 줄 + 양식 호표(B-AX-ST)가 품은 몫 + 묶음 조각이 원단위 합을 넘으면 오류. 빌더의 건너뛰기를 따라 짜지 않아, 다른 구조물에서 건너뛰기를 빠뜨리면 여기서 걸림. 배정 프로젝트 어긴 자리 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -219,7 +219,7 @@ def build_handoff(
|
||||
)
|
||||
|
||||
result["double_count_violations"] = verify_double_count_boundaries(
|
||||
unit_quantity_table, material_table, table
|
||||
unit_quantity_table, material_table, table, priced_sheets, work_items
|
||||
)
|
||||
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
|
||||
result["basis_unit_warnings"] = verify_unit_matches_basis(
|
||||
|
||||
@@ -58,6 +58,8 @@ def verify_double_count_boundaries(
|
||||
unit_quantity_table: dict[str, Any] | None,
|
||||
material_table: dict[str, Any] | None,
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
work_items: list[dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""어긴 자리를 사람 말로 — 비면 경계가 지켜진 것."""
|
||||
found: list[str] = []
|
||||
@@ -91,4 +93,67 @@ def verify_double_count_boundaries(
|
||||
f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)"
|
||||
)
|
||||
found.extend(f"③ {warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table))
|
||||
found.extend(_verify_conserved(unit_quantity_table, mapping, priced_sheets, work_items))
|
||||
return found
|
||||
|
||||
|
||||
#: 여러 구조물의 성분을 **이름으로 모아 한 줄**로 세우는 인계 줄 — (줄 이름, 모으는 성분 이름들).
|
||||
#: ⚠ 이 줄들은 구조물마다 「누구 것을 뺐나」를 안 남긴다 — 그래서 물량 보존으로 본다.
|
||||
SUMMED_ROWS = (
|
||||
(RUBBLE_GROUP, frozenset({RUBBLE_GROUP})),
|
||||
("콘크리트 타설", frozenset(PLACING_TARGET_NAMES)),
|
||||
)
|
||||
_TOLERANCE_M3 = 1e-6
|
||||
|
||||
|
||||
def _verify_conserved(
|
||||
unit_quantity_table: dict[str, Any] | None,
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None,
|
||||
work_items: list[dict[str, Any]] | None,
|
||||
) -> list[str]:
|
||||
"""④ 양식 호표·묶음 조각이 품은 성분을 모음 줄이 또 셈 — **물량 보존**으로 판정(2026-09-13).
|
||||
|
||||
모음 줄 + 호표가 품은 몫 + 묶음 조각 ≤ 원단위 성분 합. 넘치면 같은 물량을 두 자리에서 셈
|
||||
(검증 프로젝트 찰쌓기: 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥ ← 실제 3.5㎥).
|
||||
⚠ 빌더마다 「무엇을 건너뛰나」를 따라 짜지 않는다 — 건너뛰기를 빠뜨린 빌더를 잡는 검사다.
|
||||
"""
|
||||
if not unit_quantity_table or work_items is None:
|
||||
return []
|
||||
covered = {
|
||||
sid: set(entry.get("covered") or [])
|
||||
for entry in priced_sheets or []
|
||||
for sid in entry.get("structure_ids") or []
|
||||
}
|
||||
found: list[str] = []
|
||||
for row_name, names in SUMMED_ROWS:
|
||||
available = held = 0.0
|
||||
for structure in unit_quantity_table.get("structures") or []:
|
||||
taken = covered.get(str(structure.get("structure_id")), set())
|
||||
for component in structure.get("components") or []:
|
||||
name = str(component.get("name") or "").strip()
|
||||
if name not in names or component.get("unit") != "㎥":
|
||||
continue
|
||||
amount = float(component.get("amount") or 0.0)
|
||||
available += amount
|
||||
held += amount if name in taken else 0.0
|
||||
in_parts = sum(
|
||||
float(part.get("quantity") or 0.0)
|
||||
for row in work_items
|
||||
for part in row.get("composite_parts") or []
|
||||
if isinstance(part, dict)
|
||||
and part.get("from_components")
|
||||
and set(part["from_components"]) <= names
|
||||
)
|
||||
summed = sum(
|
||||
float(row.get("quantity") or 0.0)
|
||||
for row in work_items
|
||||
if row.get("name") == row_name and not row.get("composite_parts")
|
||||
)
|
||||
if summed + held + in_parts > available + _TOLERANCE_M3:
|
||||
found.append(
|
||||
f"④ 「{row_name}」 줄 {summed:g}㎥ + 양식 호표가 품은 {held:g}㎥ + 묶음 조각 "
|
||||
f"{in_parts:g}㎥ 가 원단위 합 {available:g}㎥ 를 넘음 — 호표·조각이 품은 성분을"
|
||||
" 모음 줄이 또 셈"
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -120,6 +120,29 @@ def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오
|
||||
check_materials_before_surcharge(book)
|
||||
|
||||
|
||||
def test_양식_호표가_품은_기초잡석을_모음_줄이_또_세면_오류() -> None:
|
||||
"""④ 검증 프로젝트 찰쌓기 실화 — 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥(실제 3.5㎥)."""
|
||||
table = {
|
||||
"structures": [
|
||||
{**_structure("masonry_wet", ("기초잡석", "unit_price", 4.8)), "structure_id": "s1"},
|
||||
{**_structure("masonry_dry", ("기초잡석", "unit_price", 3.5)), "structure_id": "s2"},
|
||||
]
|
||||
}
|
||||
priced = [{"structure_ids": ["s1"], "covered": ["기초잡석", "돌쌓기", "모르터"]}]
|
||||
mapping = load_mapping()
|
||||
|
||||
def rubble(quantity: float) -> list[dict]:
|
||||
return [{"name": "기초잡석", "quantity": quantity, "composite_parts": None}]
|
||||
|
||||
ok = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(3.5))
|
||||
assert not [f for f in ok if f.startswith("④")]
|
||||
doubled = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(8.3))
|
||||
assert any(f.startswith("④ 「기초잡석」") and "4.8" in f for f in doubled)
|
||||
# 호표가 없으면 8.3 은 정상 — 판정은 물량 보존이라 빌더의 건너뛰기를 따라 짜지 않음
|
||||
plain = verify_double_count_boundaries(table, {"rows": []}, mapping, [], rubble(8.3))
|
||||
assert not [f for f in plain if f.startswith("④")]
|
||||
|
||||
|
||||
def test_구조물도_일위대가는_토공으로_가는_줄을_안_넣는다() -> None:
|
||||
template = {"code": "AX-ST-00000001", "unit_price": {"rows": [{"seq": 1, "from_row": 12}]}}
|
||||
sheet = {"rows": [{"no": 12, "unit": "㎥", "unit_amount": 1.2, "destination": "earthwork"}]}
|
||||
|
||||
Reference in New Issue
Block a user