feat(804-1): 시험 페이지 「줄 쌓기 단가산출」 tools/line-stack-test + M01 왼쪽 패널 단추

- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = localStorage
- 견본 셋 금액 = 거창 파일 원 단위(5,323 · 52,796 · 94,215 비목 넷) · 확인 = node tools/line-stack-test/check.mjs
- 접근 = main.py 정적 마운트 /tools/line-stack-test · vite 프록시 한 줄(개발 포트에서 열림) · M01 왼쪽 패널 맨 아래 「시험: 줄 쌓기」 새 탭
- 라이브러리 0(분수 = CSS) · 지우기 = 폴더 + 표시 주석 셋 덩이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 23:19:38 +09:00
co-authored by Claude Opus 5.5
parent d59874d4b4
commit 3e64aa2a79
12 changed files with 1633 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
export const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const fmtP = (n) => Math.round(n).toLocaleString("en-US");
export const fmt1 = (n) =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
export const fmtV = (n) =>
n == null || Number.isNaN(n) ? "?" : n.toLocaleString("en-US", { maximumFractionDigits: 4 });
const OP = { "*": "×", "/": "/", "×": "×", "÷": "÷", "+": "+", "-": "-", "^": "^" };
/** 식 나무 → 글(sym = 기호 · val = 값) · 기호가 든 식의 「/」 는 분수로 */
export function showAst(ast, mode, env, frac = varsIn(ast).length > 0) {
const go = (a) => showAst(a, mode, env, frac);
const bare = (a) => (a.t === "par" ? go(a.a) : go(a));
switch (ast.t) {
case "num":
return fmtV(ast.v);
case "var":
return mode === "sym"
? esc(ast.n)
: ast.n in env
? fmtV(env[ast.n])
: `<b class="bad">${esc(ast.n)}</b>`;
case "par":
return `( ${go(ast.a)} )`;
case "neg":
return `-${go(ast.a)}`;
}
if (ast.op === "/" && frac)
return `<span class="fr"><span>${bare(ast.a)}</span><span>${bare(ast.b)}</span></span>`;
return `${go(ast.a)} ${OP[ast.op]} ${go(ast.b)}`;
}
const opsText = (ops, env) =>
(ops ?? [])
.map((o) => {
let v;
try {
v = showAst(parse(o.v), "val", env);
} catch {
v = `<b class="bad">${esc(o.v)}</b>`;
}
return `${esc(OP[o.op] ?? o.op)} ${v}${o.u ? " " + esc(o.u) : ""}`;
})
.join(" ");
/** 공식 줄 — 값 줄은 넷까지 한 줄에 · 식 줄은 「이름 = 기호식 = 값식 = 결과 단위」 */
function formulaRows(ln, r, push) {
let pack = [];
const flush = () =>
pack.length && (push({ h: pack.join('<span class="gap"></span>') }), (pack = []));
(ln.rows ?? []).forEach((row, i) => {
const rr = r.rows?.[i];
if (!row.n?.trim() && !row.x?.toString().trim()) return;
const u = row.u ? " " + esc(row.u) : "";
const name = esc(row.n || "?");
if (!rr?.ast || rr.err) {
flush();
return push({ h: `${name} = ${esc(row.x)} <b class="bad">${esc(rr?.err ?? "")}</b>` });
}
const plain = rr.ast.t === "num";
const hasVar = varsIn(rr.ast).length > 0;
if (!hasVar) {
pack.push(
`${name} = ${showAst(rr.ast, "sym", rr.env)}${plain ? "" : " = " + fmtV(rr.val)}${u}`,
);
if (pack.length >= 4) flush();
return;
}
flush();
push({
h: `${name} = ${showAst(rr.ast, "sym", rr.env)} = ${showAst(rr.ast, "val", rr.env)} = ${fmtV(rr.val)}${u}`,
tall: /class="fr"/.test(showAst(rr.ast, "sym", rr.env)),
});
});
flush();
}
/** 출력 줄 목록 — {h, cols?, cls?} */
export function outRows(doc, res) {
const rows = [];
const push = (r) => rows.push(r);
push({
h: `${esc(doc.name)}${doc.unit ? ` [${esc(doc.unit)}]` : ""}`,
cols: { ...res.tot },
cls: "head",
won: true,
});
let n = 0;
let sub = 0;
(doc.items ?? []).forEach((item, ii) => {
const it = res.items[ii];
if (item.title?.trim()) {
const label = item.sub ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!item.sub) sub = 0;
push({ h: label + esc(item.title), cls: item.sub ? "item sub" : "item" });
}
// 율 줄이 소계 이름을 가지면 그 앞 같은 비목 줄은 칸 값을 안 찍음(소계 줄이 찍음)
const hideCol = (li, col) =>
item.lines.some(
(x, j) =>
j > li && x.k === "rate" && x.baseName && x.scope !== "all" && (x.baseCol || "l") === col,
);
item.lines.forEach((ln, li) => {
const r = it.lines[li];
if (r.err) return push({ h: `<b class="bad">${esc(r.err)}</b>`, err: true });
if (ln.k === "text") return push({ h: esc(ln.text) });
if (ln.k === "formula") return formulaRows(ln, r, push);
if (ln.k === "mach" || ln.k === "ref") {
for (const c of COLS) {
if (r.cols[c] === undefined) continue;
push({
h: `${COL_NAME[c]} : ${fmtP(ln.res[c])} ${opsText(ln.cops?.[c] ?? ln.ops, r.vars)} = ${fmt1(r.cols[c])}`,
});
}
return;
}
if (ln.k === "labor" || ln.k === "mat") {
const col = ln.k === "labor" ? "l" : ln.col || "m";
const v = r.cols[col];
const sep = ln.k === "labor" ? " :" : "";
return push({
h: `${esc(ln.res?.name)}${sep} ${fmtP(Number(ln.res?.price) || 0)} ${opsText(ln.ops, r.vars)} = ${fmt1(v)}`,
cols: hideCol(li, col) ? null : { sum: v, [col]: v },
});
}
if (ln.k === "rate") {
const col = ln.col || "m";
const bc = ln.baseCol || "l";
if (ln.baseName && ln.scope !== "all")
push({
h: `${esc(ln.baseName)}소계 : ${fmt1(r.base)}`,
cols: { sum: r.base, [bc]: r.base },
cls: "subsum",
});
push({ h: `${esc(ln.name)} ( ${esc(ln.of || COL_NAME[bc])}의 ${esc(ln.rate)} % )` });
push({
h: `${fmt1(r.base)} × ${esc(ln.rate)} % = ${fmt1(r.cols[col])}`,
cols: { sum: r.cols[col], [col]: r.cols[col] },
});
}
});
if (it.hasSub && COLS.some((c) => it.sum[c])) {
const s = Math.round((it.sum.l + it.sum.m + it.sum.e) * 10) / 10;
push({ h: `소 계 : ${fmt1(s)}`, cols: { sum: s, ...it.sum }, cls: "subsum" });
}
});
push({ h: `${++n}. 합 계`, cls: "item" });
for (const c of COLS) {
const t = res.terms[c];
const body =
t.length > 1
? `${t.map(fmt1).join(" + ")} = ${fmtP(res.tot[c])}`
: t.length
? fmtP(res.tot[c])
: "";
push({ h: `${COL_NAME[c]} : ${body}` });
}
push({ h: `계 : ${fmtP(res.tot.sum)}`, cols: { ...res.tot }, cls: "total", won: true });
return rows;
}
export function renderOut(doc, res) {
const cell = (r, k) => {
const v = r.cols?.[k];
if (v === undefined || v === null || (r.won && !v && k !== "sum")) return "<td></td>";
return `<td class="n">${r.won ? fmtP(v) : fmt1(v)}</td>`;
};
const body = outRows(doc, res)
.map(
(r) =>
`<tr class="${r.cls ?? ""}${r.tall ? " tall" : ""}"><td class="h">${r.h}</td>${["sum", ...COLS].map((k) => cell(r, k)).join("")}</tr>`,
)
.join("");
return `<table class="out"><thead><tr><th>산 출 근 거</th><th>합 계</th><th>노무비</th><th>재료비</th><th>경 비</th></tr></thead><tbody>${body}</tbody></table>`;
}