feat(b08): 양식이 안 품은 토공·버림 성분은 전개 값을 남김(㉯) — 남길 목록을 코드에 못박음(터파기·되메우기·잔토처리·버림콘크리트) · 목록 밖은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값 · 구조물도 장에도 남긴 줄을 사유와 함께 보임

936be972 실측: 고정형(봉화 제6호표) 가져오기 → 구조물터파기·되메우기·잔토·버림 타설 전과 같음(전엔 −6,037,712원) · 본체 차이는 미완으로 금액 밖이 된 돌쌓기 2줄 −4,062,480 뿐 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 16:38:48 +09:00
co-authored by Claude Opus 5
parent 33ff0c50f0
commit fc825dc9d3
2 changed files with 123 additions and 2 deletions
@@ -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"]
@@ -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)) # 남긴 줄 차례가 양식 줄과 안 겹침