// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 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]) : `${esc(ast.n)}`; case "par": return `( ${go(ast.a)} )`; case "neg": return `-${go(ast.a)}`; } if (ast.op === "/" && frac) return `${bare(ast.a)}${bare(ast.b)}`; 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 = `${esc(o.v)}`; } 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('') }), (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)} ${esc(rr?.err ?? "")}` }); } 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: `${esc(r.err)}`, 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 ""; return `${r.won ? fmtP(v) : fmt1(v)}`; }; const body = outRows(doc, res) .map( (r) => `${r.h}${["sum", ...COLS].map((k) => cell(r, k)).join("")}`, ) .join(""); return `${body}
산 출 근 거합 계노무비재료비경 비
`; }