// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 계산·편집. 셀 = sheet.excel[n].cells["행,열"] = { value, formula? }. // 수식: 셀 참조·범위·+−×÷^·&·비교·괄호·숫자·문자·SUM·AVERAGE·MIN·MAX·ABS·INT·ROUND·ROUNDUP·ROUNDDOWN·TRUNC·SQRT·PRODUCT·IF. // 셀 합(btnSumRight·Top·Left·Bottom, 원본 ActionAdd* 와 같은 규칙): 고른 셀에서 그 방향으로 빈칸·숫자 셀을 지나 // 글자 셀이나 끝에서 멈춘 범위로 =SUM(…) 을 넣는다. const EstExcelSheet = (() => { const letters = (n) => { let s = ""; for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s; return s; }; const parseRef = (text) => { const m = String(text) .toUpperCase() .replace(/\$/g, "") .match(/^([A-Z]{1,3})(\d+)$/); if (!m) return null; return { col: [...m[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1, row: Number(m[2]) - 1, }; }; const key = (row, col) => `${row},${col}`; const round = (v, d, mode) => { const k = 10 ** d, x = v * k; const r = mode === "up" ? Math.sign(x) * Math.ceil(Math.abs(x) - 1e-9) : mode === "down" ? Math.trunc(x) : Math.sign(x) * Math.round(Math.abs(x)); return r / k; }; const fns = { SUM: (a) => a.flat().reduce((n, v) => n + (typeof v === "number" ? v : 0), 0), AVERAGE: (a) => { const v = a.flat().filter((x) => typeof x === "number"); return v.length ? v.reduce((n, x) => n + x, 0) / v.length : 0; }, MIN: (a) => Math.min(...a.flat().filter((x) => typeof x === "number")), MAX: (a) => Math.max(...a.flat().filter((x) => typeof x === "number")), PRODUCT: (a) => a .flat() .filter((x) => typeof x === "number") .reduce((n, x) => n * x, 1), ABS: ([x]) => Math.abs(num(x)), INT: ([x]) => Math.floor(num(x)), SQRT: ([x]) => Math.sqrt(num(x)), ROUND: ([x, d]) => round(num(x), num(d)), ROUNDUP: ([x, d]) => round(num(x), num(d), "up"), ROUNDDOWN: ([x, d]) => round(num(x), num(d), "down"), TRUNC: ([x, d = 0]) => round(num(x), num(d), "down"), IF: ([c, a = true, b = false]) => (c && c !== 0 ? a : b), }; function num(v) { if (Array.isArray(v)) v = v.flat()[0]; if (typeof v === "number") return v; if (typeof v === "boolean") return v ? 1 : 0; const n = Number(String(v ?? "").replaceAll(",", "")); return Number.isFinite(n) ? n : 0; } function tokenize(text) { const tokens = [], re = /\s*("(?:[^"]|"")*"|\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|[A-Za-z_][A-Za-z0-9_.]*(?=\()|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|[-+*/^&=<>(),%:])/y; let m; re.lastIndex = 0; while (re.lastIndex < text.length && (m = re.exec(text))) tokens.push(m[1]); if (re.lastIndex < text.trimEnd().length) throw Error(`수식 해석 오류: ${text}`); return tokens; } function evaluate(text, get) { const t = tokenize(String(text).replace(/^=/, "").replace(/×/g, "*").replace(/÷/g, "/")); let i = 0; const peek = () => t[i], next = () => t[i++]; const range = (a, b) => { const p = parseRef(a), q = parseRef(b), out = []; for (let r = Math.min(p.row, q.row); r <= Math.max(p.row, q.row); r++) for (let c = Math.min(p.col, q.col); c <= Math.max(p.col, q.col); c++) out.push(get(r, c)); return out; }; function primary() { const tok = next(); if (tok === undefined) throw Error("수식이 끝나지 않았습니다."); if (tok === "(") { const v = compare(); if (next() !== ")") throw Error("괄호가 맞지 않습니다."); return v; } if (tok === "-") return -num(power()); if (tok === "+") return num(power()); if (tok.startsWith('"')) return tok.slice(1, -1).replaceAll('""', '"'); if (/^\d/.test(tok)) return Number(tok); if (/^(TRUE|FALSE)$/i.test(tok)) return /^TRUE$/i.test(tok); if (tok.includes(":")) { const [a, b] = tok.split(":"); return range(a, b); } if (peek() === "(" && fns[tok.toUpperCase()]) { next(); const args = []; if (peek() !== ")") do args.push(compare()); while (peek() === "," && next()); if (next() !== ")") throw Error("함수 괄호가 맞지 않습니다."); return fns[tok.toUpperCase()](args); } const ref = parseRef(tok); if (ref) return get(ref.row, ref.col); throw Error(`모르는 수식 낱말: ${tok}`); } function percent() { let v = primary(); while (peek() === "%") (next(), (v = num(v) / 100)); return v; } function power() { let v = percent(); while (peek() === "^") (next(), (v = num(v) ** num(percent()))); return v; } function product() { let v = power(); while (peek() === "*" || peek() === "/") v = next() === "*" ? num(v) * num(power()) : num(v) / num(power()); return v; } function sum() { let v = product(); while (peek() === "+" || peek() === "-") v = next() === "+" ? num(v) + num(product()) : num(v) - num(product()); return v; } function concat() { let v = sum(); while (peek() === "&") (next(), (v = `${v ?? ""}${sum() ?? ""}`)); return v; } function compare() { let v = concat(); while (["=", "<>", "<", ">", "<=", ">="].includes(peek())) { const op = next(), w = concat(); v = { "=": v == w, "<>": v != w, "<": v < w, ">": v > w, "<=": v <= w, ">=": v >= w }[op]; } return v; } const value = compare(); if (i < t.length) throw Error(`수식 뒤 남은 글: ${t.slice(i).join("")}`); return Array.isArray(value) ? value.flat()[0] : value; } // 시트 전체 다시 계산(수식 셀). 오류 셀은 value 를 「#오류」로 두고 errors 에 모은다. function recalc(cells) { const done = new Set(), active = new Set(), errors = []; function get(row, col) { const k = key(row, col), cell = cells[k]; if (!cell) return null; if (typeof cell.formula !== "string" || done.has(k)) return cell.value; if (active.has(k)) throw Error(`순환 참조: ${letters(col)}${row + 1}`); active.add(k); try { const v = evaluate(cell.formula, get); cell.value = typeof v === "number" ? Number(v.toFixed(10)) : v; } finally { active.delete(k); done.add(k); } return cell.value; } for (const k of Object.keys(cells)) { const [r, c] = k.split(",").map(Number); try { get(r, c); } catch (error) { cells[k].value = "#오류"; errors.push(`${letters(c)}${r + 1}: ${error.message}`); } } return errors; } // 원본 셀 합: dir = right·left·up·down(Top=up). 고른 셀에서 반대쪽으로 빈칸·숫자 셀을 지나 글자 셀/끝에서 멈춤. function sumFormula(cells, row, col, dir) { const step = { left: [0, -1], right: [0, 1], up: [-1, 0], down: [1, 0] }[dir]; if (!step) throw Error("합 방향을 고르세요."); let r = row + step[0], c = col + step[1], last = null; const limit = { rows: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[0]))), cols: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[1]))), }; while (r >= 0 && c >= 0 && r < limit.rows && c < limit.cols) { const v = cells[key(r, c)]?.value; if (v != null && v !== "" && typeof v !== "number") break; last = [r, c]; r += step[0]; c += step[1]; } if (!last) throw Error("더할 셀이 없습니다."); const a = `${letters(Math.min(col + step[1], last[1]))}${Math.min(row + step[0], last[0]) + 1}`, b = `${letters(Math.max(col + step[1], last[1]))}${Math.max(row + step[0], last[0]) + 1}`; return `SUM(${a}:${b})`; } // 산출서 자리수(1 반올림·2 절사·3 올림) function fix(value, digits = 2, mode = 2) { if (typeof EstMath !== "undefined") return EstMath.fixed(value, digits, mode); return round(value, digits, mode === 1 ? undefined : mode === 3 ? "up" : "down"); } // 연결된 셀 값 → 항목 수량 function itemValues(sheet) { const out = new Map(); if (sheet.excelOff) return out; // 일반형으로 돌린 산출서는 셀 연결을 쓰지 않음 const digits = sheet.options?.digits ?? 2, mode = sheet.options?.roundMode ?? 2; for (const [code, l] of Object.entries(sheet.excelLinks || {})) { const v = sheet.excel?.[l.n]?.cells?.[key(l.row, l.col)]?.value; if (typeof v === "number") out.set(code, fix(v, digits, mode)); } return out; } // 가져올 때 추정 연결: 다시 계산한 셀 중 값이 항목 수량과 같은 셀 하나(합계 수식 → 아래쪽 우선). function inferLinks(sheet) { const links = {}, digits = sheet.options?.digits ?? 2, mode = sheet.options?.roundMode ?? 2; (sheet.excel || []).forEach((s) => recalc(s.cells)); for (const item of sheet.items || []) { const target = Number(item.GN); if (!target) continue; const found = []; (sheet.excel || []).forEach((s, n) => { for (const [k, c] of Object.entries(s.cells)) if (typeof c.value === "number" && fix(c.value, digits, mode) === target) { const [row, col] = k.split(",").map(Number); found.push({ n, row, col, sum: /^SUM\(/i.test(String(c.formula ?? "")) }); } }); found.sort((a, b) => Number(b.sum) - Number(a.sum) || b.row - a.row || a.col - b.col); if (found[0]) links[item.Code] = { n: found[0].n, row: found[0].row, col: found[0].col, inferred: true }; } return links; } return { evaluate, recalc, sumFormula, parseRef, letters, itemValues, inferLinks }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstExcelSheet;