Files
Aislo/tools/est-test/native-excel-sheet.cjs
T
eomsangdonandClaude Opus 5.5 d38d2a2893 feat(est-test): 엑셀형 수량산출서 계산·편집과 원가 행 자리수
- 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부
- 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8)
- 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:24:06 +09:00

260 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 원본 엑셀형 수량산출서(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(),
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;