261 lines
9.9 KiB
JavaScript
261 lines
9.9 KiB
JavaScript
// 원본 엑셀형 수량산출서(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;
|