2026-01-08 17:05:27 +09:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 17:11:46 +09:00
|
|
|
|