- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = 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>
182 lines
6.6 KiB
JavaScript
182 lines
6.6 KiB
JavaScript
// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
|
||
import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
|
||
|
||
export const esc = (s) =>
|
||
String(s ?? "").replace(
|
||
/[&<>"]/g,
|
||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>`;
|
||
}
|