From fb3442709f326e4b94b1df6dc6cb3af1b593ecac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 22:55:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(axis):=20=EC=9D=B4=EC=A4=91=EA=B3=84?= =?UTF-8?q?=EC=83=81=20=E2=91=A3=20=E2=80=94=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=EA=B0=80=20=ED=92=88=EC=9D=80=20=EC=84=B1?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=EB=AA=A8=EC=9D=8C=20=EC=A4=84=EC=9D=B4=20?= =?UTF-8?q?=EB=98=90=20=EC=84=B8=EB=A9=B4=20=EB=A9=88=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기초잡석 줄·콘크리트 타설 줄은 여러 구조물 성분을 이름으로 모아 한 줄로 세워 누구 것을 뺐는지 안 남김. 그래서 물량 보존으로 봄 — 모음 줄 + 양식 호표(B-AX-ST)가 품은 몫 + 묶음 조각이 원단위 합을 넘으면 오류. 빌더의 건너뛰기를 따라 짜지 않아, 다른 구조물에서 건너뛰기를 빠뜨리면 여기서 걸림. 배정 프로젝트 어긴 자리 0건. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 2 +- .../B08_Quantity_Engine_Handoff_Boundaries.py | 65 +++++++++++++++++++ .../tester/test_double_count_boundaries.py | 23 +++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 198f6d42..e3b7f446 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -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( diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py index 8a287933..c4e0ad5b 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -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 diff --git a/resources/tester/test_double_count_boundaries.py b/resources/tester/test_double_count_boundaries.py index d66da883..97a41721 100644 --- a/resources/tester/test_double_count_boundaries.py +++ b/resources/tester/test_double_count_boundaries.py @@ -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"}]}