import { compute } from "./calc.js"; // 줄 쌓기 단가산출 시험 — 엑셀 「단가산출」 블록 글 → 줄(일곱 종류로 읽히는 만큼 · 못 읽는 줄은 글 줄) const NUM = /^-?[\d,]*\.?\d+\.?$|^-?\d[\d,]*\.$/; const isNum = (t) => NUM.test(t); const num = (t) => Number(String(t).replace(/,/g, "").replace(/\.$/, "")); const clean = (t) => String(t) .replace(/(\d)\.(?!\d)/g, "$1") .replace(/,(?=\d{3})/g, ""); const OPS = ["×", "÷", "/", "*"]; const KEY = { 노무비: "l", 재료비: "m", 경비: "e" }; /** 「× 4 인 ÷ 170 ㎡」 → 꼬리 [{op, v, u}] · 괄호 등 못 읽으면 null */ export function parseOps(s, known = {}) { const tk = s.trim().split(/\s+/).filter(Boolean); const ops = []; let i = 0; while (i < tk.length) { const op = tk[i] === "*" ? "×" : tk[i]; let v = tk[i + 1]; if (!OPS.includes(tk[i]) || v === undefined) return null; if (isNum(v)) { v = clean(v); // 값이 위 공식의 Q · QH 와 같으면 이름으로 이음(틀을 고치면 따라 바뀜) const hit = Object.entries(known).find(([n, val]) => /^Q/.test(n) && String(val) === v); if (hit) v = hit[0]; } else if (!/^[A-Za-z]\w*$/.test(v)) return null; i += 2; const u = []; while (i < tk.length && !OPS.includes(tk[i])) u.push(tk[i++]); if (u.some((x) => /[()]/.test(x))) return null; ops.push({ op, v, u: u.join(" ") }); } return ops; } /** 「q = 0.7 ㎥ K = 0.55 f = 1. / 1.325 = 0.75」 → 줄 [{n, x, u}] */ export function parseDefs(line) { const re = /(^|\s)([A-Za-z][A-Za-z0-9]*)\s*=\s/g; const starts = []; let m; while ((m = re.exec(line))) { const before = line.slice(starts.length ? starts.at(-1).body : 0, m.index).trim(); if (starts.length && /[+\-×÷*/(]$/.test(before)) continue; starts.push({ n: m[2], at: m.index, body: m.index + m[0].length }); } return starts.map((s, i) => { const seg = line.slice(s.body, starts[i + 1]?.at ?? line.length).trim(); const parts = seg.split(/\s=\s/); const last = parts.at(-1).trim(); const lm = last.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/); if (parts.length === 1) return { n: s.n, x: lm ? clean(lm[1]) : clean(last), u: lm ? lm[2] : "" }; return { n: s.n, x: clean(parts[0]), u: lm ? lm[2] : "", got: lm ? num(lm[1]) : null }; }); } /** 율 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」 → 율 줄 */ function rateHead(s, next) { const m = s.match(/^(.*?)\s*\(\s*(\S+?)의\s*([\d.]+)\s*%\s*\)\s*$/); if (!m || !/×\s*[\d.]+\s*%\s*=/.test(next ?? "")) return null; const bc = /노무/.test(m[2]) ? "l" : /경비/.test(m[2]) ? "e" : "m"; return { k: "rate", name: m[1], of: m[2], baseCol: bc, rate: String(num(m[3])), col: "m" }; } /** 분수 세 줄(분자 · 「Q = ─ … = 23.39 ㎥ / hr」 · 분모) → 줄 하나 · 기호 반 · 값 반으로 나눔 */ function parseFrac(top, mid, bot) { const mm = mid.match(/^([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*)$/); if (!mm) return null; const parts = mm[2].split(/\s=\s/); const sym = parts[0]; const groups = sym.match(/─(\s*─)*/g) ?? []; const half = (s) => { const t = s.trim().split(/\s+/); return t.length % 2 ? null : t.slice(0, t.length / 2); }; const a = half(top); const b = half(bot); if (!a || !b || !groups.length) return null; if (groups.length > 1 && (a.length !== groups.length || b.length !== groups.length)) return null; let k = 0; const x = sym.replace(/─(\s*─)*/g, () => { const [na, nb] = groups.length > 1 ? [a[k], b[k]] : [a.join(" "), b.join(" ")]; k++; return `(${na}) / (${nb})`; }); const tail = parts .at(-1) .trim() .match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/); return { n: mm[1], x: clean(x), u: tail && parts.length > 1 ? tail[2] : "", got: tail ? num(tail[1]) : null, }; } export function parsePaste(text) { const lines = String(text) .replace(/\t/g, " ") .split(/\r?\n/) .map((s) => s.replace(/\s+/g, " ").trim()); const doc = { name: "붙여넣은 단가산출", unit: "", items: [] }; let expect = null; let item = null; let summary = false; let pendingBase = ""; let skipRate = false; const known = {}; const sums = { l: [], m: [], e: [] }; const stat = { read: 0, text: 0 }; const cur = () => item ?? ((item = { title: "", lines: [] }), doc.items.push(item), item); const add = (ln) => (cur().lines.push(ln), ln.k === "text" ? stat.text++ : stat.read++); const formula = () => { const last = cur().lines.at(-1); if (last?.k === "formula") return last; const f = { k: "formula", tpl: null, rows: [] }; add(f); return f; }; const addRow = (r) => { formula().rows.push({ n: r.n, x: r.x, u: r.u }); if (r.got != null) known[r.n] = String(r.got); else if (isNum(r.x)) known[r.n] = r.x; }; for (let i = 0; i < lines.length; i++) { const s = lines[i]; if (!s || /^단가산출$|^산 ?출 ?근 ?거/.test(s)) continue; let m = s.match(/^(\d+)\.(\S.*?)\[(.+?)\]\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*$/); if (m && !doc.items.length) { doc.name = m[2].trim(); doc.unit = m[3]; if (m[4]) expect = { sum: num(m[4]), l: num(m[5] ?? 0), m: num(m[6] ?? 0), e: num(m[7] ?? 0) }; continue; } // 분수 세 줄 if (lines[i + 1]?.includes("─")) { const r = parseFrac(s, lines[i + 1], lines[i + 2] ?? ""); if (r) { addRow(r); i += 2; continue; } for (const t of [s, lines[i + 1], lines[i + 2] ?? ""]) add({ k: "text", text: t }); i += 2; continue; } if ((m = s.match(/^(\d+)\s*\.\s+(.*)$/)) || (m = s.match(/^([가-하])\s*\.\s+(.*)$/))) { summary = /^(합계|소계)$/.test(m[2].replace(/\s/g, "")); if (summary) continue; item = { title: m[2], lines: [], ...(/[가-하]/.test(m[1]) ? { sub: true } : {}) }; doc.items.push(item); const rm = rateHead(m[2], lines[i + 1]); if (rm) { add({ ...rm, name: "", scope: "all", baseName: "" }); pendingBase = ""; i++; } continue; } const key = s.split(":")[0].replace(/\s/g, ""); if (summary && /^(노무비|재료비|경비|계)$/.test(key)) { if (KEY[key]) { const nums = s .slice(s.indexOf(":") + 1) .split("=")[0] .match(/[\d,]+\.?\d*/g) ?? []; sums[KEY[key]].push(...nums.map(num)); } continue; } if (/^소계$/.test(key)) continue; if (s.includes(":") && /소계$/.test(key)) { pendingBase = key.replace(/소계$/, ""); continue; } // 율 줄 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」 const rm = rateHead(s, lines[i + 1]); if (rm) { add({ ...rm, baseName: pendingBase, scope: "item" }); pendingBase = ""; i++; continue; } // 「q = 0.7 K = 0.55」 값 · 식 줄 if (/^[A-Za-z][A-Za-z0-9]*\s*=\s/.test(s) && !s.includes(":")) { parseDefs(s).forEach(addRow); continue; } // 「노무비 : 59,020 ÷ 23.39 = 2,523.3」 셋 → 기계 · 참조 줄 if (KEY[key] && s.includes(":")) { const rhs = s .slice(s.indexOf(":") + 1) .split("=")[0] .trim(); const pm = rhs.match(/^([\d,]+\.?)\s+(.*)$/); const ops = pm && parseOps(pm[2], known); if (!pm || !ops) { if (rhs) add({ k: "text", text: s }); continue; } const c = KEY[key]; let last = cur().lines.at(-1); if (!( last && (last.k === "mach" || last.k === "ref") && !(c in last.res) && last.res._open )) { const ref = (item?.title ?? "").match(/단산\s*(\d+)\s*참조/); last = ref ? { k: "ref", res: { no: Number(ref[1]), name: item.title, _open: true }, ops } : { k: "mach", res: { name: item?.title ?? "", _open: true }, ops }; add(last); } last.res[c] = num(pm[1]); if (JSON.stringify(ops) !== JSON.stringify(last.ops)) (last.cops ??= {})[c] = ops; continue; } // 「치즐 ( 223,000. × 0.006 ) ÷ 5.」 → 괄호 걷음(곱나눔 차례 그대로라 값 같음) const s2 = s.replace(/\(\s*([\d,]+\.?\s+[×÷][^()]*?)\s*\)/, "$1"); // 「보통인부 : 172,068 ÷ 8. ÷ 23.39 = 919.5」 → 인력 if ((m = s2.match(/^([^:]+?)\s*:\s*([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/))) { const ops = parseOps(m[3], known); if (ops) { add({ k: "labor", res: { name: m[1], price: num(m[2]) }, ops }); continue; } } // 「합판(내수),대전 (…) 11,455. × 1.03 ㎡ × 38. % = 4,483.4」 → 재료 if ((m = s2.match(/^(.*\S)\s+([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/)) && !s.includes(":")) { const ops = parseOps(m[3], known); if (ops) { add({ k: "mat", col: "m", res: { name: m[1], price: num(m[2]) }, ops }); continue; } } add({ k: "text", text: s }); } for (const it of doc.items) for (const ln of it.lines) if (ln.res) delete ln.res._open; // 율 줄이 든 비목 = 합계 줄에 그 값이 놓인 칸 const res = compute(doc); doc.items.forEach((it, ii) => it.lines.forEach((ln, li) => { if (ln.k !== "rate") return; const v = Object.values(res.items[ii].lines[li].cols)[0]; const col = ["m", "e", "l"].find((c) => sums[c].includes(v)) ?? ["m", "e", "l"].find((c) => sums[c].includes(Math.floor(v))); if (col) ln.col = col; }), ); return { doc, expect, stat }; }