feat(799-1): 수식 탭 단가산출 소계 · 끝수 · 합계 — 블록마다 노 · 재 · 경 소계 보이기만(금액 그대로)

- 서버 master_equation.subtotals — 호표 줄(하위 로직 블록 한 줄씩 · 자원 · 덧줄)의 버린 줄 비목 + 끝수(합계 − 줄 합)
- 부품 ui_template_equation_sum — 줄 이음 아래 소계 표(M01 · M02 수식 탭 같이)
- 시험 test_799_1_subtotals — 줄 합 + 끝수 = 합계 · 합계 = 엔진 끝수 입힌 비목 합(전후 같음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:29:26 +09:00
co-authored by Claude Sonnet 5.5
parent b608d2aa3d
commit d13b5b1753
6 changed files with 235 additions and 1 deletions
@@ -15,6 +15,7 @@ import { createBanner } from "@ui/ui_template_banner";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createEqChain } from "@ui/ui_template_equation_line";
import { createEqSum } from "@ui/ui_template_equation_sum";
import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation";
import {
el,
@@ -457,6 +458,7 @@ export function createLabEquation(opt: LabEquationOptions) {
return;
}
const edit = canEdit();
const sum = createEqSum(eq);
body.replaceChildren(
createEqChain(eq.줄, {
path: "",
@@ -472,6 +474,7 @@ export function createLabEquation(opt: LabEquationOptions) {
onVar: edit ? varDialog : undefined,
slot: edit ? editor : undefined,
}),
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
);
}
@@ -389,6 +389,39 @@ def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict:
}
def subtotals(logic: dict, cut: dict, total: dict) -> dict:
"""호표 줄 → 소계(799-1 · 보이기만) — 하위 로직 줄 = 블록마다 · 그 밖 자원 · 덧줄은 한 줄씩 + 끝수.
값 = `master_cut.engine` 이 버린 줄 비목(0.1 원) 그대로 · 합계 = 엔진 비목 합(1 원 버림) ·
끝수 = 합계 − 줄 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함).
"""
items, adds = logic.get("호표") or [], 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))}
)
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))
rest = {
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in rows), Decimal(0))
for k in mf.BUNDLE
}
return {"줄": rows, "끝수": rest}
def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
"""로직 키 → 수식 {열쇠, 이름, 결과단위, 줄, 풀이, 변수표, 합계 | 결과} — 셈은 엔진 한 벌."""
row = master.logic(key)
@@ -402,6 +435,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
more = {"표시": step["표시"]} if step.get("표시") is not None else {}
out_lines.append(_line(ctx, "중간", name, step["식"], env.get(name), **more))
total: dict = {}
sub: dict = {}
if "결과" in row:
out_lines.append(_line(ctx, "결과", "결과", row["결과"]["식"], raw["결과"]))
total = {"결과": raw["결과"]}
@@ -415,6 +449,8 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
one = _line(ctx, "덧줄", extra["이름"], extra["식"], got, 차례=at)
out_lines.append(one | {"비목": extra.get("비목")})
total = {k: cut.get(k) for k in mf.BUNDLE}
if depth == 0:
sub = {"소계": subtotals(row, cut, total)}
return _clean(
{
"열쇠": row.get("키"),
@@ -424,6 +460,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
"풀이": list(ctx.used.values()),
"변수표": table(row, master),
"합계": total,
**sub,
}
)
+102
View File
@@ -0,0 +1,102 @@
"""799-1 단가산출 소계 · 끝수 · 합계 지킴(보이기만 — 금액 셈은 그대로).
① 예시 셋 — 굴착기(블록 한 줄) · 거푸집(자원 + 덧줄) · 지게(자원) : 줄 합 + 끝수 = 합계(비목마다).
② 표본 — 호표 로직 여럿 : 합계 = 엔진 끝수 입힌 비목 합(전후 같음) · 줄 합 + 끝수 = 합계 · 결과 로직은 소계 없음.
③ 부품 짝 — `ui_template_equation_sum.ts` 가 서버 칸 이름을 그대로 읽음.
"""
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")
mcut = importlib.import_module("master_cut")
meq = importlib.import_module("master_equation")
COSTS = ("노무비", "재료비", "경비", "계")
def _whole():
return store.loaded()[1]
def _logics() -> list[dict]:
"""M01 로직 줄 전부(키 순)."""
w = _whole()
return [
r
for tid, rows in sorted(w.index.items())
if mf.mk.GROUP_OF.get(tid) == "로직"
for r in rows.values()
]
def _export(key: str) -> dict:
w = _whole()
return meq.export(w, key, meq.given_of(w, w.logic(key), None))
def _closes(eq: dict) -> None:
sub = eq["소계"]
for k in COSTS:
got = sum((r[k] for r in sub["줄"]), D(0)) + sub["끝수"][k]
assert got == eq["합계"][k], (eq["열쇠"], k, got, eq["합계"][k])
def test_굴착기_블록_한_줄_끝수():
eq = _export("GC001148")
rows = eq["소계"]["줄"]
assert [r["갈래"] for r in rows] == ["블록"] and rows[0]["이름"] == "굴착기(무한궤도)"
assert (rows[0]["노무비"], rows[0]["재료비"], rows[0]["경비"]) == (
D("437.1"),
D("88.2"),
D("116.0"),
)
assert eq["합계"]["계"] == D("641.0") and eq["소계"]["끝수"]["계"] == D("-0.3")
_closes(eq)
def test_거푸집_자원_덧줄_지게_자원():
eq = _export("GC000211")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원", "덧줄"]
_closes(eq)
eq = _export("GC001140")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원"]
_closes(eq)
def test_표본_합계는_엔진_끝수_그대로():
w = _whole()
keys = [x["키"] for x in _logics() if x.get("호표")][::25]
assert keys, "표본 없음"
seen = 0
for key in keys:
try:
eq = _export(key)
except mf.FormulaError:
continue
if "소계" not in eq:
continue
seen += 1
_closes(eq)
raw = mf.run(w, key, meq.given_of(w, w.logic(key), None))
cut = mcut.engine(w, key, raw)
assert {k: eq["합계"][k] for k in COSTS} == {k: cut[k] for k in COSTS}, key
assert seen >= 10
def test_결과_로직은_소계_없음():
key = next(x["키"] for x in _logics() if "결과" in x)
assert "소계" not in _export(key)
def test_화면_부품이_서버_칸을_읽음():
ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8")
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)" in line
+33
View File
@@ -322,3 +322,36 @@
:is(.ui-eq__block-body, .ui-eq__bad)[hidden] {
display: none !important;
}
/* ── 단가산출 소계 · 합계(799-1) ── */
.ui-eq__sum {
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border);
font-variant-numeric: tabular-nums;
}
.ui-eq__sum-row {
display: grid;
grid-template-columns: minmax(var(--spacing-96), 2fr) repeat(4, minmax(0, 1fr));
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.ui-eq__sum-head {
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-eq__sum-cell {
text-align: right;
}
.ui-eq__sum-row[data-eq-sum-kind="끝수"] {
color: var(--color-text-secondary);
}
.ui-eq__sum-row[data-eq-sum-kind="합계"] {
font-weight: var(--font-weight-semibold);
}
+13 -1
View File
@@ -44,7 +44,17 @@ export interface EqNode {
사슬?: boolean;
}
/** 로직 하나의 수식(줄 · 변수표 · 합계) */
/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 */
export interface EqSumRow {
갈래: string;
이름: string;
노무비: unknown;
재료비: unknown;
경비: unknown;
계: unknown;
}
/** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */
export interface EqTree {
열쇠: string;
이름: string;
@@ -52,6 +62,8 @@ export interface EqTree {
줄: EqLine[];
변수표: EqVarRow[];
합계: Record<string, unknown>;
/** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown> };
}
export interface EqVarRow {
+47
View File
@@ -0,0 +1,47 @@
/* =============================================================================
* ui_template_equation_sum.ts
* 부품 「수식」 셋째 파일(799-1 · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
* 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합)
* 값 = 서버 `소계`(`master_equation.subtotals` · 버린 줄 비목 0.1 원) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림
* 쓰는 법 = `createEqSum(tree)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임
* ========================================================================== */
import { eqText, type EqTree } from "./ui_template_equation";
const COLS = ["노무비", "재료비", "경비", "계"] as const;
const KIND: Record<string, string> = { 자원: "자기 자원", 덧줄: "덧줄" };
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
const out = document.createElement(tag);
out.className = className;
if (text) out.textContent = text;
return out;
};
function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement {
const line = node("div", "ui-eq__sum-row");
line.dataset.eqSumKind = kind;
line.append(node("span", "ui-eq__sum-name", name));
for (const k of COLS) {
const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0");
cell.dataset.eqSumCol = k;
line.append(cell);
}
return line;
}
export function createEqSum(tree: Pick<EqTree, "합계" | "소계">): HTMLElement | null {
const sub = tree.소계;
if (!sub?.줄.length) return null;
const box = node("div", "ui-eq__sum");
box.dataset.eqSum = "";
const head = node("div", "ui-eq__sum-row ui-eq__sum-head");
head.append(node("span", "ui-eq__sum-name", "소계"));
for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k));
box.append(head);
for (const r of sub.줄) box.append(row(r.갈래, KIND[r.갈래] ?? r.이름, (k) => r[k]));
const rest = sub.끝수;
if (COLS.some((k) => Number(rest[k] ?? 0) !== 0)) box.append(row("끝수", "끝수", (k) => rest[k]));
box.append(row("합계", "합계", (k) => tree.합계[k]));
return box;
}