diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 1a3e183b..ea98851b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -153,6 +153,20 @@ def template_sheet( #: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례). _PER_LENGTH_UNITS = frozenset({"m"}) +#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음). +#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(−6,037,712원 실측). +#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음). +KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트") +KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)" + + +def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]: + """목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로.""" + names = {str(row.get("name") or "") for row in template_rows} + return [ + row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names + ] + def _library_rows( body: dict[str, Any], solved: list[dict[str, Any]], billing: float @@ -295,7 +309,9 @@ def replace_with_templates( spec=str(result.get("spec") or ""), ) ) - quantity.components = components + quantity.components = components + _kept( + quantity.components, body["rows"], lambda component: component.name + ) # 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음. quantity.library_item = str(template.get("name") or quantity.type_id) @@ -343,7 +359,23 @@ def apply_templates( continue members = sheet.get("members") or [] billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0 - sheet["rows"] = _library_rows(body, solved[index], billing) + library_rows = _library_rows(body, solved[index], billing) + start = max((row["no"] for row in library_rows), default=0) + kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name")) + sheet["rows"] = library_rows + [ + { + **row, + "no": start + offset, + "formula": "", + "default_formula": "", + "rounding": None, + "default_rounding": None, + "skipped": False, + "reason": KEPT_ROW_REASON, + "error": "", + } + for offset, row in enumerate(kept, start=1) + ] sheet["unpriced_rows"] = [ row["name"] for row in sheet["rows"] diff --git a/resources/tester/test_b08_template_keep_earthwork.py b/resources/tester/test_b08_template_keep_earthwork.py new file mode 100644 index 00000000..7eaf71d3 --- /dev/null +++ b/resources/tester/test_b08_template_keep_earthwork.py @@ -0,0 +1,89 @@ +"""㉯ 양식이 안 품은 토공·버림 성분은 전개 값을 남김 (2026-09-14 브레인 판정 · 목록으로 못박음). + +실측(936be972): STmate 호표(고정형)를 가져오니 전개 성분이 통째로 갈음돼 구조물터파기 85.25→25㎥ +(−6,037,712원) · 되메우기·잔토·버림 타설도 줄었음. STmate 호표는 원래 토공을 안 품음(토공 따로 셈). +규칙 — `KEEP_ENGINE_COMPONENTS` 목록에 있는 이름은 **양식에 그 이름 줄이 없을 때** 전개 값을 보존, +목록에 없는 성분은 양식으로 갈음. 양식형(돌쌓기 찰)은 그 줄을 스스로 품어 겹치지 않음. +""" + +from __future__ import annotations + +from collections import Counter + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( + KEEP_ENGINE_COMPONENTS, + apply_templates, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + +HOPYO = { + "no": 6, + "source_code": "B00010", + "name": "기슭막이", + "spec": "H=2.0m", + "unit": "M", + "basis": [], + "contract_rows": 0, + "rows": [ + { + "name": "깬잡석찰쌓기", + "spec": "L3=45", + "amount": 2.09, + "unit": "M2", + "remark": "", + "source_code": "", + }, + ], +} +WALL = StructureInstance.model_validate( + { + "structure_id": "a", + "type_id": "masonry_wet", + "placement": "interval", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "back_len_cm": 45, "foundation": "기초유"}, + } +).model_dump() +NAMES = {"masonry_wet": "돌쌓기(찰)"} + + +def _fixed() -> dict: + return { + **recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"), + "code": "AX-ST-0000abcd", + } + + +def test_목록이_코드에_못박혀_있다() -> None: + assert set(KEEP_ENGINE_COMPONENTS) >= {"터파기", "되메우기", "잔토처리", "버림콘크리트"} + + +def test_고정형을_가져와도_토공_버림_성분은_남는다() -> None: + engine = build_table([WALL], NAMES, {}, {}, None, use_templates=False)["structures"][0] + fixed = build_table([WALL], NAMES, {}, {}, None, structure_templates={"masonry_wet": _fixed()}) + components = {c["name"]: c for c in fixed["structures"][0]["components"]} + for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트"): + before = next(c for c in engine["components"] if c["name"] == name) + assert components[name]["amount"] == before["amount"], name + assert "깬잡석찰쌓기" in components and "돌" not in components # 목록 밖은 양식으로 갈음 + + +def test_양식형은_그_줄을_스스로_품어_겹치지_않는다() -> None: + table = build_table([WALL], NAMES, {}, {}, None) + counts = Counter(c["name"] for c in table["structures"][0]["components"]) + assert all(count == 1 for count in counts.values()), counts + + +def test_구조물도_장에도_남긴_줄이_보인다() -> None: + templates = {"masonry_wet": _fixed()} + table = build_table([WALL], NAMES, {}, {}, None, structure_templates=templates) + payload = build_standard_sheets(table, {}) + apply_templates(payload, {}, templates) + names = [row["name"] for row in payload["sheets"][0]["rows"]] + assert names[0] == "깬잡석찰쌓기" and "터파기" in names and "버림콘크리트" in names + numbers = [row["no"] for row in payload["sheets"][0]["rows"]] + assert len(numbers) == len(set(numbers)) # 남긴 줄 차례가 양식 줄과 안 겹침