ERP-node/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts

260 lines
6.4 KiB
TypeScript

/**
* pop-dashboard 수식 파싱 및 평가 유틸리티
*
* 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현.
*/
import type { FormulaConfig, FormulaDisplayFormat } from "../../types";
// ===== 토큰 타입 =====
type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen";
interface Token {
type: TokenType;
value: string;
}
// ===== 토크나이저 =====
/** 수식 문자열을 토큰 배열로 분리 */
function tokenize(expression: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const expr = expression.replace(/\s+/g, "");
while (i < expr.length) {
const ch = expr[i];
// 숫자 (정수, 소수)
if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) {
let num = "";
while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) {
num += expr[i];
i++;
}
tokens.push({ type: "number", value: num });
continue;
}
// 변수 (A, B, C 등 알파벳)
if (/[A-Za-z]/.test(ch)) {
let varName = "";
while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) {
varName += expr[i];
i++;
}
tokens.push({ type: "variable", value: varName });
continue;
}
// 연산자
if ("+-*/".includes(ch)) {
tokens.push({ type: "operator", value: ch });
i++;
continue;
}
// 괄호
if (ch === "(") {
tokens.push({ type: "lparen", value: "(" });
i++;
continue;
}
if (ch === ")") {
tokens.push({ type: "rparen", value: ")" });
i++;
continue;
}
// 알 수 없는 문자는 건너뜀
i++;
}
return tokens;
}
// ===== 재귀 하강 파서 =====
/**
* 사칙연산 수식을 안전하게 평가 (재귀 하강 파서)
*
* 문법:
* expr = term (('+' | '-') term)*
* term = factor (('*' | '/') factor)*
* factor = NUMBER | VARIABLE | '(' expr ')'
*
* @param expression - 수식 문자열 (예: "A / B * 100")
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
* @returns 계산 결과 (0으로 나누기 시 0 반환)
*/
export function evaluateFormula(
expression: string,
values: Record<string, number>
): number {
const tokens = tokenize(expression);
let pos = 0;
function peek(): Token | undefined {
return tokens[pos];
}
function consume(): Token {
return tokens[pos++];
}
// factor = NUMBER | VARIABLE | '(' expr ')'
function parseFactor(): number {
const token = peek();
if (!token) return 0;
if (token.type === "number") {
consume();
return parseFloat(token.value);
}
if (token.type === "variable") {
consume();
return values[token.value] ?? 0;
}
if (token.type === "lparen") {
consume(); // '(' 소비
const result = parseExpr();
if (peek()?.type === "rparen") {
consume(); // ')' 소비
}
return result;
}
// 예상치 못한 토큰
consume();
return 0;
}
// term = factor (('*' | '/') factor)*
function parseTerm(): number {
let result = parseFactor();
while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) {
const op = consume().value;
const right = parseFactor();
if (op === "*") {
result *= right;
} else {
// 0으로 나누기 방지
result = right === 0 ? 0 : result / right;
}
}
return result;
}
// expr = term (('+' | '-') term)*
function parseExpr(): number {
let result = parseTerm();
while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) {
const op = consume().value;
const right = parseTerm();
result = op === "+" ? result + right : result - right;
}
return result;
}
const result = parseExpr();
return Number.isFinite(result) ? result : 0;
}
/**
* 수식 결과를 displayFormat에 맞게 포맷팅
*
* @param config - 수식 설정
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
* @returns 포맷된 문자열
*/
export function formatFormulaResult(
config: FormulaConfig,
values: Record<string, number>
): string {
const formatMap: Record<FormulaDisplayFormat, () => string> = {
value: () => {
const result = evaluateFormula(config.expression, values);
return formatNumber(result);
},
fraction: () => {
// "1,234 / 5,678" 형태
const ids = config.values.map((v) => v.id);
if (ids.length >= 2) {
return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`;
}
return formatNumber(evaluateFormula(config.expression, values));
},
percent: () => {
const result = evaluateFormula(config.expression, values);
return `${(result * 100).toFixed(1)}%`;
},
ratio: () => {
// "1,234 : 5,678" 형태
const ids = config.values.map((v) => v.id);
if (ids.length >= 2) {
return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`;
}
return formatNumber(evaluateFormula(config.expression, values));
},
};
return formatMap[config.displayFormat]();
}
/**
* 수식에 사용된 변수 ID가 모두 존재하는지 검증
*
* @param expression - 수식 문자열
* @param availableIds - 사용 가능한 변수 ID 배열
* @returns 유효 여부
*/
export function validateExpression(
expression: string,
availableIds: string[]
): boolean {
const tokens = tokenize(expression);
const usedVars = tokens
.filter((t) => t.type === "variable")
.map((t) => t.value);
return usedVars.every((v) => availableIds.includes(v));
}
/**
* 큰 숫자 축약 (Container Query 축소 시 사용)
*
* 1234 -> "1,234"
* 12345 -> "1.2만"
* 1234567 -> "123.5만"
* 123456789 -> "1.2억"
*/
export function abbreviateNumber(value: number): string {
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 100_000_000) {
return `${sign}${(abs / 100_000_000).toFixed(1)}`;
}
if (abs >= 10_000) {
return `${sign}${(abs / 10_000).toFixed(1)}`;
}
return `${sign}${formatNumber(abs)}`;
}
// ===== 내부 헬퍼 =====
/** 숫자를 천 단위 콤마 포맷 */
function formatNumber(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString("ko-KR");
}
// 소수점 이하 최대 2자리
return value.toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}