// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · node 로도 돎) // 끝수 = 거창 출력과 같은 자리: 줄 값 0.1 자리 버림 · 비목 합계 원 단위 버림 · 공식 변수(식인 줄) 소수 둘째 반올림 export const COLS = ["l", "m", "e"]; export const COL_NAME = { l: "노무비", m: "재료비", e: "경 비" }; const TOKEN = /\s*(?:(\d[\d,]*\.?\d*|\.\d+)|([A-Za-z가-힣_][A-Za-z0-9가-힣_]*)|([+\-*/×÷^()]))/y; export function tokenize(s) { const out = []; TOKEN.lastIndex = 0; const src = String(s ?? "").trim(); while (TOKEN.lastIndex < src.length) { const at = TOKEN.lastIndex; const m = TOKEN.exec(src); if (!m || m.index !== at) throw new Error(`읽을 수 없는 글자: ${src.slice(at).trim()[0]}`); if (m[1] !== undefined) out.push({ t: "num", v: Number(m[1].replace(/,/g, "")) }); else if (m[2] !== undefined) out.push({ t: "var", n: m[2] }); else out.push({ t: "op", v: m[3] }); if (TOKEN.lastIndex >= src.length || !src.slice(TOKEN.lastIndex).trim()) break; } return out; } /** 식 → 나무(괄호는 그대로 남겨 화면에 쓴 꼴대로 보임) */ export function parse(s) { const tk = tokenize(s); if (!tk.length) throw new Error("빈 식"); let i = 0; const peek = () => tk[i]; const isOp = (...ops) => peek()?.t === "op" && ops.includes(peek().v); const add = () => { let a = mul(); while (isOp("+", "-")) a = { t: "bin", op: tk[i++].v, a, b: mul() }; return a; }; const mul = () => { let a = unary(); while (isOp("*", "/", "×", "÷")) a = { t: "bin", op: tk[i++].v, a, b: unary() }; return a; }; const unary = () => (isOp("-") ? (i++, { t: "neg", a: unary() }) : pow()); const pow = () => { const a = atom(); return isOp("^") ? (i++, { t: "bin", op: "^", a, b: unary() }) : a; }; const atom = () => { const k = tk[i++]; if (!k) throw new Error("식이 덜 끝남"); if (k.t === "num" || k.t === "var") return k; if (k.v === "(") { const a = add(); if (!isOp(")")) throw new Error("괄호가 안 닫힘"); i++; return { t: "par", a }; } throw new Error(`「${k.v}」 자리가 틀림`); }; const ast = add(); if (i < tk.length) throw new Error(`「${tk[i].v ?? tk[i].n}」 앞에서 식이 끊김`); return ast; } export function varsIn(ast, out = []) { if (!ast) return out; if (ast.t === "var") out.includes(ast.n) || out.push(ast.n); for (const k of ["a", "b"]) if (ast[k]) varsIn(ast[k], out); return out; } export function evalAst(ast, vars) { switch (ast.t) { case "num": return ast.v; case "var": if (!(ast.n in vars)) throw new Error(`없는 이름: ${ast.n}`); return vars[ast.n]; case "par": return evalAst(ast.a, vars); case "neg": return -evalAst(ast.a, vars); } const a = evalAst(ast.a, vars); const b = evalAst(ast.b, vars); if (ast.op === "+") return a + b; if (ast.op === "-") return a - b; if (ast.op === "*" || ast.op === "×") return a * b; if (ast.op === "^") return a ** b; return a / b; } export const floor1 = (x) => Math.floor(x * 10 + 1e-6) / 10; export const floor0 = (x) => Math.floor(x + 1e-6); export const round1 = (x) => Math.round(x * 10) / 10; export const roundTo = (x, d) => Math.round(x * 10 ** d + 1e-9) / 10 ** d; /** 곱나눔 꼬리 하나 값(% 면 100 으로 나눔) */ export function opValue(op, vars) { const v = evalAst(parse(op.v), vars); return op.u?.trim() === "%" ? v / 100 : v; } export function applyOps(x, ops, vars) { for (const op of ops ?? []) { const v = opValue(op, vars); x = op.op === "×" || op.op === "*" ? x * v : x / v; } return x; } const isPlain = (ast) => ast.t === "num" || (ast.t === "neg" && ast.a.t === "num"); /** 단가산출 하나를 셈 — 줄마다 결과 · 항목 소계 · 합계 항(경쟁사 꼴) · 비목 합계 */ export function compute(doc) { const vars = {}; const terms = { l: [], m: [], e: [] }; const all = { l: 0, m: 0, e: 0 }; const items = []; const errors = []; for (const item of doc.items ?? []) { const it = { lines: [], sum: { l: 0, m: 0, e: 0 }, terms: { l: [], m: [], e: [] } }; it.hasSub = (item.lines ?? []).some((ln) => ln.k === "mach" || ln.k === "ref"); const put = (col, val, r) => { it.sum[col] = round1(it.sum[col] + val); it.terms[col].push(val); r.cols[col] = val; }; for (const ln of item.lines ?? []) { const r = { cols: {}, err: null, vars: { ...vars } }; try { if (ln.k === "formula") { r.rows = (ln.rows ?? []).map((row) => calcRow(row, vars)); } else if (ln.k === "mach" || ln.k === "ref") { for (const c of COLS) { const price = Number(ln.res?.[c]) || 0; if (price) put(c, floor1(applyOps(price, ln.cops?.[c] ?? ln.ops, vars)), r); } } else if (ln.k === "labor" || ln.k === "mat") { const col = ln.k === "labor" ? "l" : ln.col || "m"; put(col, floor1(applyOps(Number(ln.res?.price) || 0, ln.ops, vars)), r); } else if (ln.k === "rate") { const bc = ln.baseCol || "l"; const base = round1(ln.scope === "all" ? all[bc] + it.sum[bc] : it.sum[bc]); r.base = base; if (ln.baseName && ln.scope !== "all") it.terms[bc] = base ? [base] : []; put(ln.col || "m", floor1((base * Number(ln.rate || 0)) / 100), r); } } catch (e) { r.err = e.message; errors.push(e.message); } it.lines.push(r); } for (const c of COLS) { all[c] = round1(all[c] + it.sum[c]); terms[c].push(...(it.hasSub ? (it.sum[c] ? [it.sum[c]] : []) : it.terms[c])); } items.push(it); } const tot = { l: floor0(all.l), m: floor0(all.m), e: floor0(all.e) }; tot.sum = tot.l + tot.m + tot.e; return { items, terms, all, tot, errors, vars }; } /** 공식 줄 한 칸 — 「이름 = 식」 · 없는 이름은 unknown 으로(빨강) */ export function calcRow(row, vars) { const r = { val: null, err: null, unknown: [], env: { ...vars } }; if (!row.x?.toString().trim()) return r; try { r.ast = parse(row.x); r.unknown = varsIn(r.ast).filter((n) => !(n in vars)); if (r.unknown.length) { r.err = `없는 이름: ${r.unknown.join(", ")}`; return r; } const v = evalAst(r.ast, vars); r.val = isPlain(r.ast) ? v : roundTo(v, row.d ?? 2); if (row.n?.trim()) vars[row.n.trim()] = r.val; } catch (e) { r.err = e.message; } return r; }