ERP-node/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts

621 lines
16 KiB
TypeScript
Raw Normal View History

/**
* PivotGrid
* .
*/
import {
PivotFieldConfig,
PivotResult,
PivotHeaderNode,
PivotFlatRow,
PivotFlatColumn,
PivotCellValue,
DateGroupInterval,
AggregationType,
} from "../types";
import { aggregate, formatNumber, formatDate } from "./aggregation";
// ==================== 헬퍼 함수 ====================
/**
* ( )
*/
function getFieldValue(
row: Record<string, any>,
field: PivotFieldConfig
): string {
const rawValue = row[field.field];
if (rawValue === null || rawValue === undefined) {
return "(빈 값)";
}
// 날짜 그룹핑 처리
if (field.groupInterval && field.dataType === "date") {
const date = new Date(rawValue);
if (isNaN(date.getTime())) return String(rawValue);
switch (field.groupInterval) {
case "year":
return String(date.getFullYear());
case "quarter":
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
case "month":
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
case "week":
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date, "YYYY-MM-DD");
default:
return String(rawValue);
}
}
return String(rawValue);
}
/**
*
*/
function getWeekNumber(date: Date): number {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
*
*/
export function pathToKey(path: string[]): string {
return path.join("||");
}
/**
*
*/
export function keyToPath(key: string): string[] {
return key.split("||");
}
// ==================== 헤더 생성 ====================
/**
*
*/
function buildHeaderTree(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedPaths: Set<string>
): PivotHeaderNode[] {
if (fields.length === 0) return [];
// 첫 번째 필드로 그룹화
const firstField = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, firstField);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
// 정렬
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (firstField.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
// 노드 생성
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: 0,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
// 자식 노드 생성 (확장된 경우만)
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
1
);
// span 계산
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
*
*/
function buildChildNodes(
data: Record<string, any>[],
fields: PivotFieldConfig[],
parentPath: string[],
expandedPaths: Set<string>,
level: number
): PivotHeaderNode[] {
if (fields.length === 0) return [];
const field = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, field);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (field.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [...parentPath, key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: level,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
level + 1
);
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
* span (colspan/rowspan)
*/
function calculateSpan(children?: PivotHeaderNode[]): number {
if (!children || children.length === 0) return 1;
return children.reduce((sum, child) => sum + child.span, 0);
}
// ==================== 플랫 구조 변환 ====================
/**
*
*/
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
const result: PivotFlatRow[] = [];
function traverse(node: PivotHeaderNode) {
result.push({
path: node.path,
level: node.level,
caption: node.caption,
isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0),
});
if (node.isExpanded && node.children) {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
return result;
}
/**
* ( )
*/
function flattenColumns(
nodes: PivotHeaderNode[],
maxLevel: number
): PivotFlatColumn[][] {
const levels: PivotFlatColumn[][] = Array.from(
{ length: maxLevel + 1 },
() => []
);
function traverse(node: PivotHeaderNode, currentLevel: number) {
levels[currentLevel].push({
path: node.path,
level: currentLevel,
caption: node.caption,
span: node.span,
});
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, currentLevel + 1);
}
} else if (currentLevel < maxLevel) {
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levels[i].push({
path: node.path,
level: i,
caption: "",
span: node.span,
});
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return levels;
}
/**
*
*/
function getMaxColumnLevel(
nodes: PivotHeaderNode[],
totalFields: number
): number {
let maxLevel = 0;
function traverse(node: PivotHeaderNode, level: number) {
maxLevel = Math.max(maxLevel, level);
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, level + 1);
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return Math.min(maxLevel, totalFields - 1);
}
// ==================== 데이터 매트릭스 생성 ====================
/**
*
*/
function buildDataMatrix(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): Map<string, PivotCellValue[]> {
const matrix = new Map<string, PivotCellValue[]>();
// 각 셀에 대해 해당하는 데이터 집계
for (const row of flatRows) {
for (const colPath of flatColumnLeaves) {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
// 해당 행/열 경로에 맞는 데이터 필터링
const filteredData = data.filter((record) => {
// 행 조건 확인
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
// 열 조건 확인
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
// 데이터 필드별 집계
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(
values,
dataField.summaryType || "sum"
);
const formattedValue = formatNumber(
aggregatedValue,
dataField.format
);
return {
field: dataField.field,
value: aggregatedValue,
formattedValue,
};
});
matrix.set(cellKey, cellValues);
}
}
return matrix;
}
/**
* leaf
*/
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
const leaves: string[][] = [];
function traverse(node: PivotHeaderNode) {
if (!node.isExpanded || !node.children || node.children.length === 0) {
leaves.push(node.path);
} else {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
// 열 필드가 없을 경우 빈 경로 추가
if (leaves.length === 0) {
leaves.push([]);
}
return leaves;
}
// ==================== 총합계 계산 ====================
/**
*
*/
function calculateGrandTotals(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): {
row: Map<string, PivotCellValue[]>;
column: Map<string, PivotCellValue[]>;
grand: PivotCellValue[];
} {
const rowTotals = new Map<string, PivotCellValue[]>();
const columnTotals = new Map<string, PivotCellValue[]>();
// 행별 총합 (각 행의 모든 열 합계)
for (const row of flatRows) {
const filteredData = data.filter((record) => {
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
rowTotals.set(pathToKey(row.path), cellValues);
}
// 열별 총합 (각 열의 모든 행 합계)
for (const colPath of flatColumnLeaves) {
const filteredData = data.filter((record) => {
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
columnTotals.set(pathToKey(colPath), cellValues);
}
// 대총합
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = data.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
return {
row: rowTotals,
column: columnTotals,
grand: grandValues,
};
}
// ==================== 메인 함수 ====================
/**
*
*/
export function processPivotData(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedRowPaths: string[][] = [],
expandedColumnPaths: string[][] = []
): PivotResult {
// 영역별 필드 분리
const rowFields = fields
.filter((f) => f.area === "row" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const columnFields = fields
.filter((f) => f.area === "column" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const dataFields = fields
.filter((f) => f.area === "data" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const filterFields = fields.filter(
(f) => f.area === "filter" && f.visible !== false
);
// 필터 적용
let filteredData = data;
for (const filterField of filterFields) {
if (filterField.filterValues && filterField.filterValues.length > 0) {
filteredData = filteredData.filter((row) => {
const value = getFieldValue(row, filterField);
if (filterField.filterType === "exclude") {
return !filterField.filterValues!.includes(value);
}
return filterField.filterValues!.includes(value);
});
}
}
// 확장 경로 Set 변환
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
// 기본 확장: 첫 번째 레벨 모두 확장
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
const firstField = rowFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedRowSet.add(val));
}
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
const firstField = columnFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedColSet.add(val));
}
// 헤더 트리 생성
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
const columnHeaders = buildHeaderTree(
filteredData,
columnFields,
expandedColSet
);
// 플랫 구조 변환
const flatRows = flattenRows(rowHeaders);
const flatColumnLeaves = getColumnLeaves(columnHeaders);
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
// 데이터 매트릭스 생성
const dataMatrix = buildDataMatrix(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
// 총합계 계산
const grandTotals = calculateGrandTotals(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
return {
rowHeaders,
columnHeaders,
dataMatrix,
flatRows,
flatColumns: flatColumnLeaves.map((path, idx) => ({
path,
level: path.length - 1,
caption: path[path.length - 1] || "",
span: 1,
})),
grandTotals,
};
}