feat(800-4): 소계 · 합계를 기준 단가산출별 묶음으로 — 가. 이름 → 노 · 재 · 경 → 소 계 · 맨 끝 소 계(묶음 소계를 더한 식) · 블록 식 줄 아래 · 블록 줄 곁 열쇠 글 걷음

- 수식 영역: 블록 묶음은 그 블록 식 줄 바로 아래(createEqChain after) · 남은 묶음 · 소 계 · 끝수 · 합계는 맨 끝
- 금액 셈 그대로(보이기만) · 묶음 소계 합 + 끝수 = 합계 시험 추가(test_800_2)
- 정의 영역: 로직 부르기 정의의 이름 되풀이 걷음 · 상수 연결 설명

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 17:03:00 +09:00
co-authored by Claude Sonnet 5.5
parent f47991f698
commit 59f0dd90a8
6 changed files with 169 additions and 52 deletions
@@ -520,7 +520,8 @@ export function createLabEquation(opt: LabEquationOptions) {
return;
}
const edit = canEdit();
const sum = createEqSum(eq, open, page?.sum);
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
const sum = createEqSum(eq, open, page?.sum, keys);
const left = el("div", {
className: "m01-eq__left",
children: [
@@ -537,8 +538,9 @@ export function createLabEquation(opt: LabEquationOptions) {
}
: undefined,
editor: edit ? editor : undefined,
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
}),
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
],
});
defsBox = buildDefs();
@@ -94,6 +94,8 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[
name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}"
else:
link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key))
if kind == "블록" and not leaf.get(key):
link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함
name = (look.get("이름") or key) if mine else look.get("이름") or ""
name = node.get("칸") or name if kind == "찾기" else name
one = {
@@ -95,3 +95,28 @@ def test_candidates():
assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"]
assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"]
assert whole is not None
def test_groups_add_up_to_total():
"""800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경."""
for key in ("GC001148", "GC000211"):
eqn = eq.read(key, {})["수식"]
sub, total = eqn["소계"], eqn["합계"]
for k in ("노무비", "재료비", "경비"):
got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k])
assert abs(got - float(total[k])) < 1e-6, (key, k)
assert all(
abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6
for r in sub["줄"]
)
def test_screen_source_groups_and_inline_edit():
ui = ROOT / "ui_template"
sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8")
line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8")
page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8")
assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음
assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래
assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음
assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음
+43 -5
View File
@@ -253,11 +253,6 @@
color: var(--color-text-secondary);
}
.ui-eq__block-key {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__block-body {
margin-top: var(--spacing-4);
}
@@ -437,3 +432,46 @@
.ui-eq__def-empty {
color: var(--color-text-muted);
}
/* ── 기준 단가산출별 묶음(800-4 · 요구 661) — 가. 이름 → 노 · 재 · 경 → 소 계 ── */
.ui-eq__grp {
font-size: var(--text-body-sm);
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8) 0 var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.ui-eq__grp-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: 0 var(--spacing-8);
font-weight: var(--font-weight-semibold);
}
.ui-eq__grp-line {
display: flex;
align-items: baseline;
gap: var(--spacing-4);
padding: 0 var(--spacing-8) 0 var(--spacing-24);
font-variant-numeric: tabular-nums;
}
.ui-eq__grp-key {
min-width: 4.5em;
color: var(--color-text-secondary);
}
.ui-eq__grp-total .ui-eq__sum-cell::before {
content: attr(data-eq-sum-col) " ";
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__grp-total {
border-bottom: 0;
font-weight: var(--font-weight-medium);
}
+5 -4
View File
@@ -35,6 +35,8 @@ export interface EqLineOptions {
onEdit?: (line: EqLine, id: string) => void;
/** 고치는 줄의 식 칸 */
editor?: (line: EqLine, id: string) => HTMLElement;
/** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */
after?: (line: EqLine) => HTMLElement | null;
/** 결과 줄 값 뒤 단위 */
resultUnit?: string;
}
@@ -108,10 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
body.hidden = !on;
};
body.hidden = true;
box.append(
div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")),
body,
);
box.append(div("ui-eq__block-row", head, bundle(node)), body);
if (o.open.has(id)) toggle(true);
return box;
}
@@ -206,6 +205,8 @@ export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
if (line.뺌) root.dataset.removed = "1";
root.append(div("ui-eq__main", body));
blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o)));
const extra = o.after?.(line);
if (extra) root.append(extra);
return root;
}
+90 -41
View File
@@ -1,10 +1,12 @@
/* =============================================================================
* ui_template_equation_sum.ts
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
* 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합)
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림
* M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
* 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 ·
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 800-4 · 요구 655-1 · 661 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
* 묶음 = 기준 단가산출마다(하위 로직 블록 · 자기 자원 · 덧줄) 「가. 이름」 머리 → 노무비 · 재료비 · 경비 줄 → 소 계(노 · 재 · 경 · 계 칸)
* 맨 끝 「소 계」 묶음 = 묶음 소계를 더한 식(노무비 : 2,299.4 + 5,162 = 7,461.4) → 끝수(합계 − 묶음 합 · 0 이면 줄 없음) → 합계(엔진 비목 합)
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 소계가 없으면(결과 로직) 아무것도 안 그림
* M02 SK 묶음 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
* 자리 = 하위 로직 블록 묶음은 그 블록 식 줄 바로 아래(`inline` · 줄 열쇠 = 블록 이름 · `createEqChain` 의 `after`) · 나머지(자기 자원 · 덧줄 · 더한 기초) · 맨 끝 소 계 · 끝수 · 합계는 `tail`
* 쓰는 법 = `createEqSum(tree, open?, hooks?, lineKeys?)` → { inline, tail } | null — `lineKeys` = 수식 영역 호표 줄 열쇠들 · `open` = 펼친 블록 이름표 모음 ·
* `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기)
* ========================================================================== */
@@ -14,8 +16,14 @@ import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_templa
import { createEqChain } from "./ui_template_equation_line";
const COLS = ["노무비", "재료비", "경비", "계"] as const;
const PARTS = ["노무비", "재료비", "경비"] as const;
type Col = (typeof COLS)[number];
const KIND: Record<string, string> = { 자원: "자기 자원", 덧줄: "덧줄" };
const TONE: Record<string, BadgeTone> = { 더함: "success", 덮음: "warning", 뺌: "neutral" };
const ORDER = "가나다라마바사아자차카타파하";
/** 묶음 번호 — 가 · 나 · 다 … (열네 개를 넘으면 숫자) */
const mark = (at: number): string => (at < ORDER.length ? (ORDER[at] ?? "") : String(at + 1));
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
const out = document.createElement(tag);
@@ -24,7 +32,12 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement =>
return out;
};
function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void {
const num = (v: unknown): number => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
function cells(line: HTMLElement, get: (k: Col) => unknown): void {
for (const k of COLS) {
const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0");
cell.dataset.eqSumCol = k;
@@ -32,7 +45,7 @@ function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): v
}
}
function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement {
function row(kind: string, name: string, get: (k: Col) => unknown): HTMLElement {
const line = node("div", "ui-eq__sum-row");
line.dataset.eqSumKind = kind;
line.append(node("span", "ui-eq__sum-name", name));
@@ -40,19 +53,33 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn
return line;
}
/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */
function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
const item = node("div", "ui-eq__sum-item");
const line = node("div", "ui-eq__sum-row");
line.dataset.eqSumKind = r.갈래;
const name = node("span", "ui-eq__sum-name");
/** 묶음 안 한 줄 「노무비 : 값」 */
const part = (label: string, text: string): HTMLElement => {
const line = node("div", "ui-eq__grp-line");
line.append(
node("span", "ui-eq__grp-key", label),
node("span", "ui-eq__op", ":"),
node("span", "ui-eq__grp-val", text),
);
line.dataset.eqGrpPart = label;
return line;
};
/** 기준 단가산출 묶음 하나 — 「가. 이름」 머리(+ 상태 · 접힘 수식) → 노 · 재 · 경 줄 → 소 계 */
function group(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
const box = node("div", "ui-eq__grp");
box.dataset.eqSumItem = r.갈래;
box.dataset.eqSumState = r.상태 ?? "바탕";
const name = node("div", "ui-eq__grp-head");
const title = r.갈래 === r.이름 ? (KIND[r.갈래] ?? r.이름) : r.이름;
const tree = r.나무;
const id = `sum:${at}:${r.이름}`;
const body = node("div", "ui-eq__sum-tree");
body.hidden = true;
name.append(node("span", "ui-eq__grp-mark", `${mark(at)}.`));
if (tree) {
const head = createButton({
label: r.이름,
label: title,
variant: "ghost",
size: 22,
iconStart: "chevronRight",
@@ -66,67 +93,89 @@ function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks)
if (!body.childElementCount)
body.append(
hooks?.body?.(r, at) ??
createEqChain(tree.줄, {
path: id,
open,
resultUnit: tree.결과단위,
}),
createEqChain(tree.줄, { path: id, open, resultUnit: tree.결과단위 }),
);
} else open.delete(id);
item.dataset.open = on ? "1" : "";
box.dataset.open = on ? "1" : "";
body.hidden = !on;
};
name.append(head);
if (open.has(id)) toggle(true);
} else name.append(node("span", "ui-eq__sum-label", r.이름));
} else name.append(node("span", "ui-eq__sum-label", title));
if (r.상태 && r.상태 !== "바탕") {
const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : "";
name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 }));
}
name.append(...(hooks?.head?.(r, at) ?? []));
if (r.합밖) {
name.append(createBadge("합 밖", "neutral", { size: 18 }));
line.dataset.eqSumOut = "1";
}
const out = !!r.합밖;
if (out) name.append(createBadge("합 밖", "neutral", { size: 18 }));
const notes: string[] = [];
if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
if (out) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null)
notes.push(`× ${eqText(r.수량)}`);
if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함")
notes.push(`원래 ${eqText(r.원래)}`);
if (r.까닭) notes.push(r.까닭);
if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · ")));
line.dataset.eqSumState = r.상태 ?? "바탕";
line.append(name);
cells(line, (k) => r[k]);
item.dataset.eqSumItem = r.갈래;
item.append(line, body);
return item;
box.append(name, body);
for (const k of PARTS) if (num(r[k]) !== 0) box.append(part(k, eqText(r[k])));
const total = row("소계", "소 계", (k) => r[k]);
total.classList.add("ui-eq__grp-total");
if (out) total.dataset.eqSumOut = "1";
box.append(total);
return box;
}
/** 맨 끝 「소 계」 묶음 — 묶음 소계를 더한 식 */
function final(rows: EqSumRow[], at: number): HTMLElement {
const box = node("div", "ui-eq__grp");
box.dataset.eqSumItem = "소계";
const head = node("div", "ui-eq__grp-head");
head.append(
node("span", "ui-eq__grp-mark", `${mark(at)}.`),
node("span", "ui-eq__sum-label", "소 계"),
);
box.append(head);
for (const k of PARTS) {
const adds = rows.map((r) => num(r[k])).filter((v) => v !== 0);
if (!adds.length) continue;
const text = adds.map((v) => eqText(v)).join(" + ");
const sum = eqText(adds.reduce((a, b) => a + b, 0));
box.append(part(k, adds.length > 1 ? `${text} = ${sum}` : text));
}
return box;
}
export interface EqSumParts {
/** 블록 이름 → 그 블록 식 줄 아래에 끼울 묶음 */
inline: Map<string, HTMLElement>;
/** 수식 영역 맨 끝 — 남은 묶음 · 소 계 · 끝수 · 합계 */
tail: HTMLElement;
}
export function createEqSum(
tree: Pick<EqTree, "합계" | "소계" | "답까닭">,
open: Set<string> = new Set(),
hooks?: EqSumHooks,
): HTMLElement | null {
lineKeys: string[] = [],
): EqSumParts | null {
const sub = tree.소계;
if (!sub?.줄.length) return null;
const inline = new Map<string, HTMLElement>();
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);
sub.줄.forEach((r, at) => {
if (r.상태) box.append(itemRow(r, open, at, hooks));
else
box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k]));
const one = group(r, open, at, hooks);
if (r.갈래 === "블록" && lineKeys.includes(r.이름) && !inline.has(r.이름))
inline.set(r.이름, one);
else box.append(one);
});
box.append(final(sub.줄, sub.줄.length));
const rest = sub.끝수;
const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0);
if (odd) box.append(row("끝수", "끝수", (k) => rest[k]));
box.append(row("합계", "합계", (k) => tree.합계[k]));
const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · ");
if (note) box.append(node("div", "ui-eq__sum-note", note));
return box;
return { inline, tail: box };
}