feat(801-3): 수식 탭 소계 · 합계 묶음 다시 — 돈 묶음 하나 = 머리 없음 · 계 / 둘 이상 = 소계 / 단가산출 둘 이상 = 합계 · 안쪽 말 0

- 묶음 한 곳 master_equation_group.bundles: 부른 · 더한 단가산출 하나씩 + 직접 줄(인력 · 재료 · 기계 · 덧줄) 한 묶음 · 돈 없는 줄 · 뺀 줄 = 묶음 아님 · 합계 밖 줄 = 밖 글
- M01 subtotals · M02 M02_Sangeun_Equation_Full 같은 꼴 · 끝수 셈 그대로 · 금액 전후 같음
- 부품 ui_template_equation_sum: 자기 자원 · 덧줄 글 0 · 묶음 하나 = 머리 없음 · 계 · 둘 이상 = 가. 머리 · 소 계 · 단가산출 둘 이상 = 합계
- 옛 시험 기대 고침: test_799_1(갈래 자원 · 덧줄 → 직접 한 묶음) · test_796_2c(줄마다 한 행 → 묶음 · 낱줄 · 밖) — 묶음 꼴이 바뀜
- 새 test_801_3_groups 5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 17:44:49 +09:00
co-authored by Claude Sonnet 5.5
parent 90478a43c3
commit 47f260e012
8 changed files with 294 additions and 85 deletions
@@ -28,6 +28,7 @@ from pathlib import Path
import master_auto as ma
import master_cut as mcut
import master_equation_group as grp
import master_equation_view as view
import master_formula as mf
import master_material as mm
@@ -401,36 +402,30 @@ def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict:
def subtotals(logic: dict, cut: dict, total: dict) -> dict:
"""호표 줄 → 소계(799-1 · 보이기만) — 하위 로직 줄 = 블록마다 · 그 밖 자원 · 덧줄은 한 줄씩 + 끝수.
"""호표 줄 → 소계 묶음(799-1 · 801-3 · 보이기만) — 부른 단가산출 = 묶음 하나씩 · 직접 줄 = 한 묶음 + 끝수.
값 = `master_cut.engine` 이 버린 줄 비목(0.1 원) 그대로 · 합계 = 엔진 비목 합(1 원 버림) ·
끝수 = 합계 − 줄 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함).
끝수 = 합계 − 묶음 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함). 묶음 꼴 = `master_equation_group`.
"""
items, adds = logic.get("호표") or [], logic.get("덧줄") or []
items = logic.get("호표") or []
skip = mcut.ms.skipped(logic) if mcut.ms.uses(logic) else set()
rows: list[dict] = []
pool: dict[str, dict] = {}
for n, line in enumerate(cut.get("줄") or []):
if n in skip:
continue
split = {k: line.get("비목", {}).get(k) or Decimal(0) for k in mf.COST_ITEMS}
if n < len(items) and items[n].get("종류") == "로직":
rows.append({"갈래": "블록", "이름": str(items[n].get("이름") or ""), **split})
continue
kind = "덧줄" if n >= len(items) else "자원"
one = pool.setdefault(
kind, {"갈래": kind, "이름": kind, **dict.fromkeys(mf.COST_ITEMS, Decimal(0))}
item = items[n] if n < len(items) else {}
kind = "블록" if item.get("종류") == "로직" else "자원" if item else "덧줄"
rows.append(
{"갈래": kind, "이름": str(item.get("이름") or ""), "종류": item.get("종류"), **split}
| {"계": sum(split.values(), Decimal(0))}
)
for k in mf.COST_ITEMS:
one[k] += split[k]
rows += [pool[k] for k in ("자원", "덧줄") if k in pool]
for one in rows:
one["계"] = sum((one[k] for k in mf.COST_ITEMS), Decimal(0))
got = grp.bundles(rows)
rest = {
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in rows), Decimal(0))
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in got["줄"]), Decimal(0))
for k in mf.BUNDLE
}
return {"줄": rows, "끝수": rest}
return {"줄": got["줄"], "끝수": rest}
def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
@@ -0,0 +1,68 @@
"""수식 탭 소계 묶음(PLAN 801-3 · 요구 662-3) — 돈 묶음 한 곳 · M01(`master_equation`) · M02(`M02_Sangeun_Equation_Full`) 같은 꼴.
묶음 = 부른 · 더한 단가산출(갈래 블록) 하나씩 + 직접 줄(인력 · 재료 · 기계 · 덧줄 · 율) 전부 한 묶음(갈래 직접).
돈 없는 줄(계 0 · 바탕) · 뺀 줄 = 묶음 아님(식 줄로만 보임) · 합계 밖 줄 = `밖` 으로만.
머리 · 소계 · 합계 · 계 글자 판정 = 화면 부품(`ui_template_equation_sum.ts`) — 묶음 둘 이상 = 머리 · 소계 ·
단가산출(블록) 둘 이상 = 합계 · 아니면 계.
시험 `resources/tester/test_801_3_groups.py`.
"""
from __future__ import annotations
from decimal import Decimal
COSTS = ("노무비", "재료비", "경비")
_WORDS = ("인력", "재료", "기계") # 직접 묶음 이름 차례
def _zero(row: dict) -> bool:
"""돈 없는 바탕 줄 · 뺀 줄 — 묶음 아님."""
if row.get("상태") == "뺌":
return True
return row["계"] == 0 and row.get("상태") in (None, "바탕")
def _name(direct: list[dict]) -> str:
"""직접 묶음 이름 — 자원 갈래 말(인력 · 재료 · 기계) · 율 줄 = 이름 (율 %) · 덧줄뿐 = 비목 말."""
words = [w for w in _WORDS if any(r.get("종류") == w for r in direct)]
words += [
f"{r['이름']} ({r['수량']}%)" if r.get("수량") not in (None, "") else str(r["이름"])
for r in direct
if r["갈래"] == "율"
]
if not words:
words = [k for k in COSTS if any(r[k] != 0 for r in direct)]
return " · ".join(words)
def bundles(rows: list[dict]) -> dict:
"""줄(갈래 블록 | 자원 | 덧줄 | 율 · 비목 칸 · 계) → `{줄: 묶음들, 밖: 합계 밖 줄들}` — 금액은 더하기만(셈 안 바꿈)."""
blocks: list[dict] = []
direct: list[dict] = []
out: list[dict] = []
for row in rows:
if row.get("합밖"):
out.append({"이름": row["이름"], "밖금액": row.get("밖금액")})
elif _zero(row):
continue
elif row["갈래"] == "블록":
blocks.append(row)
else:
direct.append(row)
lines = list(blocks)
if direct:
one = {
"갈래": "직접",
"이름": _name(direct),
**{k: sum((r[k] for r in direct), Decimal(0)) for k in COSTS},
}
one["계"] = sum((one[k] for k in COSTS), Decimal(0))
mine = [ # 더한 · 덮은 줄 — 묶음 속에 한 줄씩(고친 줄을 숨기지 않음)
{k: r[k] for k in ("이름", "상태", "계", "원래") if r.get(k) is not None}
for r in direct
if r.get("상태") in ("더함", "덮음")
]
if mine:
one["낱줄"] = mine
lines.append(one)
return {"줄": lines, "밖": out}
@@ -41,7 +41,11 @@ def _read(folder, doc, inputs=None):
def _rows(eq) -> dict:
return {r["cid"]: r for r in eq["소계"]["줄"]}
"""묶음(801-3) — 더한 · 부른 단가산출 = cid 로 · 직접 줄 묶음 속 더함 · 덮음 줄 = 이름으로."""
got = {r["cid"]: r for r in eq["소계"]["줄"] if "cid" in r}
for r in eq["소계"]["줄"]:
got |= {x["이름"]: x for x in r.get("낱줄", [])}
return got
def _closes(eq, ans) -> None:
@@ -80,21 +84,19 @@ def test_A_added_removed_covered_total_equals_sk(made):
_closes(eq, ans)
rows = _rows(eq)
# 덮음 — 형틀목공 품 덮개 · 원래 = 고침 없는 SK 의 같은 줄 금액
cover = rows["labor:LB000007#0"]
cover = rows["형틀목공"] # 직접 묶음 속 덮은 줄(이름)
was = next(x for x in bare["줄"] if x["cid"] == "labor:LB000007#0")["금액"]
assert cover["상태"] == "덮음" and "품" in cover["덮개"]
assert cover["상태"] == "덮음"
assert _d(cover["원래"]) == _d(was)
assert cover["노무비"] == D("137895") # 품 0.5 × 단가 275,790
# 뺌 — 지운 줄 = 금액 0 · 원래 금액만
gone = rows["labor:LB000002#0"]
assert gone["상태"] == "뺌" and gone["계"] == 0
assert _d(gone["원래"]) == _d(next(x for x in bare["줄"] if x["cid"] == gone["cid"])["금액"])
# 더함 — 기초(굴착기 641 × 2) · 인력 · 기초는 수식 나무
basic, man = rows["add:#0"], rows["add:#1"]
assert (
basic["상태"] == man["상태"] == "더함" and basic["갈래"] == "블록" and man["갈래"] == "자원"
)
assert basic["계"] > 0 and man["노무비"] > 0
assert cover["계"] == D("137895") # 품 0.5 × 단가 275,790
# 뺌 — 지운 줄 = 묶음 아님(식 줄 표지로만 · 아래 `out["뺌"]`)
assert "labor:LB000002#0" not in rows and "보통인부" not in rows
# 더함 — 기초(굴착기 641 × 2) = 묶음 · 인력 = 직접 묶음 속 더한 줄 · 기초는 수식 나무
basic = rows["add:#0"]
man = next(x for r in eq["소계"]["줄"] for x in r.get("낱줄", []) if x["상태"] == "더함")
assert basic["상태"] == man["상태"] == "더함" and basic["갈래"] == "블록"
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["블록", "직접"]
assert basic["계"] > 0 and man["계"] > 0
assert basic["나무"]["열쇠"] == "GC001148" and basic["나무"]["줄"]
# 나무 합계 × 수량 = 행(같은 입력)
assert abs(basic["나무"]["합계"]["계"] * 2 - basic["계"]) <= 2
@@ -106,7 +108,8 @@ def test_A_added_removed_covered_total_equals_sk(made):
assert out["뺌"] is True and out["표지"] == []
# 고침 없는 SK(plain)는 표지도 더함도 없음 — 합계 = 같은 바탕 금액
flat = _read(me, _doc(me, plain))
assert {r["상태"] for r in flat["소계"]["줄"]} == {"바탕"}
assert {r.get("상태", "바탕") for r in flat["소계"]["줄"]} == {"바탕"}
assert [r["갈래"] for r in flat["소계"]["줄"]] == ["직접"] # 801-3 한 묶음 · 낱줄 없음
assert flat["합계"]["계"] == _d(bare["기준"]["계"])
assert not any("표지" in x for x in flat["줄"])
@@ -151,9 +154,9 @@ def test_B_unit_cover_rate_and_reference(world):
eq = _read(me, doc)
_closes(eq, ans)
rows = _rows(eq)
price = rows["labor:LB000002#0"]
assert price["상태"] == "덮음" and "단가" in price["덮개"] and price["원래"] is not None
assert rows["add:#0"]["갈래"] == "율" and rows["add:#0"]["경비"] > 0
pool = next(r for r in eq["소계"]["줄"] if r["갈래"] == "직접")
price = next(x for x in pool["낱줄"] if x["상태"] == "덮음")
assert price["원래"] is not None and "sub2임시 율" in pool["이름"] and pool["경비"] > 0
assert rows["add:#1"]["갈래"] == "블록" and rows["add:#1"]["상태"] == "더함"
assert _line(eq, "호표", price["이름"])["표지"] == ["단가"]
@@ -171,7 +174,7 @@ def test_C_imported_sk_same_rule(world):
eq = _read(me, doc)
_closes(eq, ans)
rows = _rows(eq)
assert rows["add:#0"]["상태"] == "더함" and rows[cid]["상태"] == "덮음"
assert [x["상태"] for x in eq["소계"]["줄"][-1]["낱줄"]] == ["덮음", "더함"], rows
def test_plain_sk_has_answer_table(world):
@@ -183,7 +186,7 @@ def test_plain_sk_has_answer_table(world):
def test_D_imported_outside_total_line_not_in_sum(world):
"""D 가져온 식 `합밖` 줄 = 합계에 안 듦 → 행 0 · 밖금액 · 끝수에 안 섞임."""
"""D 가져온 식 `합밖` 줄 = 합계에 안 듦 → 묶음 아님 · `밖` 글 · 끝수에 안 섞임."""
me = world["personal"]
row = _imported_row()
row["호표"][1] = {**row["호표"][1], "합밖": True}
@@ -193,6 +196,6 @@ def test_D_imported_outside_total_line_not_in_sum(world):
ans = _calc(me, doc)
eq = _read(me, doc)
_closes(eq, ans)
out = [r for r in eq["소계"]["줄"] if r.get("합밖")]
assert len(out) == 1 and out[0]["계"] == 0 and out[0]["밖금액"] > 0
out = eq["소계"]["밖"]
assert len(out) == 1 and out[0]["밖금액"] > 0
assert abs(eq["소계"]["끝수"]["계"]) < 1
+3 -3
View File
@@ -62,10 +62,10 @@ def test_굴착기_블록_한_줄_끝수():
def test_거푸집_자원_덧줄_지게_자원():
eq = _export("GC000211")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원", "덧줄"]
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"] # 801-3 직접 줄 한 묶음
_closes(eq)
eq = _export("GC001140")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원"]
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"]
_closes(eq)
@@ -96,7 +96,7 @@ def test_결과_로직은_소계_없음():
def test_화면_부품이_서버_칸을_읽음():
ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8")
for name in ("노무비", "재료비", "경비", "끝수", "합계", "자원", "덧줄"):
for name in ("노무비", "재료비", "경비", "끝수", "합계"):
assert name in ts
line = Path("M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8")
assert "createEqSum(eq, open" in line # 796-2e 덧단추 자리 인자가 뒤에 붙음
+117
View File
@@ -0,0 +1,117 @@
"""801-3 수식 탭 소계 묶음(요구 662-3) 지킴 — 금액 셈은 그대로 · 묶음만 다시.
① 묶음 = 부른 단가산출(갈래 블록) 하나씩 + 직접 줄 한 묶음(갈래 직접) · 돈 없는 줄 = 묶음 아님 · 안쪽 말 0
② M01 전수 — 묶음 합 + 끝수 = 합계(엔진 비목 합 그대로) · 직접 묶음 하나뿐
③ 부품 — 묶음 하나 = 머리 없음 · 계 / 둘 이상 = 머리 · 소계 / 단가산출 둘 이상 = 합계 · 안쪽 말 글 0
"""
from __future__ import annotations
import importlib
from decimal import Decimal as D
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
meq = importlib.import_module("master_equation")
grp = importlib.import_module("master_equation_group")
COSTS = ("노무비", "재료비", "경비", "계")
BAD = ("자기", "자원", "덧줄", "블록") # 코드 안쪽 말 — 화면 글에 0
def _w():
return store.loaded()[1]
def _export(key: str) -> dict:
w = _w()
return meq.export(w, key, meq.given_of(w, w.logic(key), None))
def _row(kind, name, cost=(0, 0, 0), **more):
got = dict(zip(COSTS[:3], (D(x) for x in cost), strict=True))
return {"갈래": kind, "이름": name, **got, "계": sum(got.values(), D(0))} | more
def test_묶음_함수_하나뿐_직접_한_묶음():
got = grp.bundles(
[
_row("블록", "굴착기", (1, 2, 3)),
_row("블록", "쉬는 단가산출"), # 돈 0 = 묶음 아님
_row("자원", "보통인부", (5, 0, 0), 종류="인력"),
_row("자원", "시멘트", (0, 7, 0), 종류="재료"),
_row("덧줄", "공구손료", (0, 0, 1)),
]
)
assert [r["갈래"] for r in got["줄"]] == ["블록", "직접"]
assert got["줄"][0]["이름"] == "굴착기" and got["줄"][1]["이름"] == "인력 · 재료"
assert (got["줄"][1]["노무비"], got["줄"][1]["재료비"], got["줄"][1]["경비"]) == (5, 7, 1)
only = grp.bundles([_row("덧줄", "공구손료", (0, 0, 1))])["줄"]
assert [r["이름"] for r in only] == ["경비"] and not grp.bundles([_row("블록", "빈")])["줄"]
def test_M02_상태_줄():
got = grp.bundles(
[
_row("자원", "보통인부", (5, 0, 0), 상태="더함", 종류="인력"),
_row("자원", "형틀목공", (4, 0, 0), 상태="덮음", 원래=D(9), 종류="인력"),
_row("자원", "지운 줄", 상태="뺌", 종류="인력"),
_row("자원", "밖 줄", 합밖=True, 밖금액=D(8)),
]
)
assert [x["이름"] for x in got["줄"][0]["낱줄"]] == ["보통인부", "형틀목공"]
assert got["밖"] == [{"이름": "밖 줄", "밖금액": D(8)}] and got["줄"][0]["계"] == 9
def test_거푸집_한_묶음_굴착기_한_묶음():
eq = _export("GC000211")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"]
assert eq["소계"]["줄"][0]["이름"] == "인력 · 재료"
eq = _export("GC001148")
rows = eq["소계"]["줄"]
assert [(r["갈래"], r["이름"]) for r in rows] == [("블록", "굴착기(무한궤도)")]
assert eq["합계"]["계"] == D("641.0") and eq["소계"]["끝수"]["계"] == D("-0.3")
def test_M01_전수_묶음_합_끝수_합계_안쪽_말_0():
w = _w()
many = seen = 0
for tid, rows in sorted(w.index.items()):
if mf.mk.GROUP_OF.get(tid) != "로직":
continue
for logic in rows.values():
if not logic.get("호표"):
continue
try:
eq = _export(logic["키"])
except mf.FormulaError:
continue
sub = eq.get("소계")
if not sub:
continue
seen += 1
lines = sub["줄"]
assert sum(r["갈래"] == "직접" for r in lines) <= 1, logic["키"]
assert all(r["계"] != 0 for r in lines), logic["키"]
for r in lines:
assert not any(b in r["이름"] for b in BAD), (logic["키"], r["이름"])
for k in COSTS:
got = sum((r[k] for r in lines), D(0)) + sub["끝수"][k]
assert got == eq["합계"][k], (logic["키"], k)
many += sum(r["갈래"] == "블록" for r in lines) >= 2
assert seen > 400 and many >= 10, (seen, many)
def test_부품_머리_소계_합계_규칙_안쪽_말_0():
ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8")
assert "sub.줄.length >= 2" in ts # 묶음 하나 = 머리 · 소계 없음
assert '"합계" : "계"' in ts and '=== "블록").length >= 2' in ts # 합계 = 단가산출 둘 이상
ui = "".join(
line
for line in ts.splitlines(keepends=True)
if not line.lstrip().startswith(("*", "/*", "//", "/**"))
)
for word in ("자기 자원", "덧줄", "KIND"):
assert word not in ui, word