ERP-node/backend-node/src/services/dataService.ts

1431 lines
48 KiB
TypeScript
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.

/**
* 동적 데이터 서비스
*
* 주요 특징:
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
*
* 보안:
* - 테이블명은 영문, 숫자, 언더스코어만 허용
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
* - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
*/
import { query, queryOne } from "../database/db";
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
interface GetTableDataParams {
tableName: string;
limit?: number;
offset?: number;
orderBy?: string;
filters?: Record<string, string>;
userCompany?: string;
}
interface ServiceResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
/**
* 접근 금지 테이블 목록 (블랙리스트)
* 시스템 중요 테이블 및 보안상 접근 금지할 테이블
*/
const BLOCKED_TABLES = [
"pg_catalog",
"pg_statistic",
"pg_database",
"pg_user",
"information_schema",
"session_tokens", // 세션 토큰 테이블
"password_history", // 패스워드 이력
];
/**
* 테이블 이름 검증 정규식
* SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용
*/
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
class DataService {
/**
* 중복 데이터 제거 (메모리 내 처리)
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price = true인 행 찾기
selectedRow = rows.find(row => row.base_price === true) || rows[0];
break;
case "current_date":
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
const today = new Date();
today.setHours(0, 0, 0, 0); // 시간 제거
selectedRow = rows.find(row => {
const startDate = row.start_date ? new Date(row.start_date) : null;
const endDate = row.end_date ? new Date(row.end_date) : null;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
break;
default:
selectedRow = rows[0];
}
result.push(selectedRow);
}
return result;
}
/**
* 테이블 접근 검증 (공통 메서드)
*/
private async validateTableAccess(
tableName: string
): Promise<{ valid: boolean; error?: ServiceResponse<any> }> {
// 1. 테이블명 형식 검증 (SQL 인젝션 방지)
if (!TABLE_NAME_REGEX.test(tableName)) {
return {
valid: false,
error: {
success: false,
message: `유효하지 않은 테이블명입니다: ${tableName}`,
error: "INVALID_TABLE_NAME",
},
};
}
// 2. 블랙리스트 검증
if (BLOCKED_TABLES.includes(tableName)) {
return {
valid: false,
error: {
success: false,
message: `접근이 금지된 테이블입니다: ${tableName}`,
error: "TABLE_ACCESS_DENIED",
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
return {
valid: false,
error: {
success: false,
message: `테이블을 찾을 수 없습니다: ${tableName}`,
error: "TABLE_NOT_FOUND",
},
};
}
return { valid: true };
}
/**
* 테이블 데이터 조회
*/
async getTableData(
params: GetTableDataParams
): Promise<ServiceResponse<any[]>> {
const {
tableName,
limit = 10,
offset = 0,
orderBy,
filters = {},
userCompany,
} = params;
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// 동적 SQL 쿼리 생성
let sql = `SELECT * FROM "${tableName}"`;
const queryParams: any[] = [];
let paramIndex = 1;
// WHERE 조건 생성
const whereConditions: string[] = [];
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
}
}
// 사용자 정의 필터 추가
for (const [key, value] of Object.entries(filters)) {
if (
value &&
key !== "limit" &&
key !== "offset" &&
key !== "orderBy" &&
key !== "userLang"
) {
// 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
continue; // 유효하지 않은 컬럼명은 무시
}
whereConditions.push(`"${key}" ILIKE $${paramIndex}`);
queryParams.push(`%${value}%`);
paramIndex++;
}
}
// WHERE 절 추가
if (whereConditions.length > 0) {
sql += ` WHERE ${whereConditions.join(" AND ")}`;
}
// ORDER BY 절 추가
if (orderBy) {
// ORDER BY 검증 (SQL 인젝션 방지)
const orderParts = orderBy.split(" ");
const columnName = orderParts[0];
const direction = orderParts[1]?.toUpperCase();
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
const validDirection = direction === "DESC" ? "DESC" : "ASC";
sql += ` ORDER BY "${columnName}" ${validDirection}`;
}
} else {
// 기본 정렬: 최신순 (가능한 컬럼 시도)
const dateColumns = [
"created_date",
"regdate",
"reg_date",
"updated_date",
"upd_date",
];
const tableColumns = await this.getTableColumnsSimple(tableName);
const availableDateColumn = dateColumns.find((col) =>
tableColumns.some((tableCol) => tableCol.column_name === col)
);
if (availableDateColumn) {
sql += ` ORDER BY "${availableDateColumn}" DESC`;
}
}
// LIMIT과 OFFSET 추가
sql += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
queryParams.push(limit, offset);
console.log("🔍 실행할 쿼리:", sql);
console.log("📊 쿼리 파라미터:", queryParams);
// 쿼리 실행
const result = await query<any>(sql, queryParams);
return {
success: true,
data: result,
};
} catch (error) {
console.error(`데이터 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 테이블 컬럼 정보 조회
*/
async getTableColumns(tableName: string): Promise<ServiceResponse<any[]>> {
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
const columns = await this.getTableColumnsSimple(tableName);
// PK 컬럼 정보 조회
const pkColumns = await this.getPrimaryKeyColumns(tableName);
// 컬럼 라벨 정보 추가
const columnsWithLabels = await Promise.all(
columns.map(async (column) => {
const label = await this.getColumnLabel(
tableName,
column.column_name
);
return {
columnName: column.column_name,
columnLabel: label || column.column_name,
dataType: column.data_type,
isNullable: column.is_nullable === "YES",
defaultValue: column.column_default,
isPrimaryKey: pkColumns.includes(column.column_name), // PK 여부 추가
};
})
);
return {
success: true,
data: columnsWithLabels,
};
} catch (error) {
console.error(`컬럼 정보 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 테이블의 Primary Key 컬럼 목록 조회
*/
private async getPrimaryKeyColumns(tableName: string): Promise<string[]> {
try {
const result = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[tableName]
);
return result.map((row) => row.attname);
} catch (error) {
console.error(`PK 컬럼 조회 오류 (${tableName}):`, error);
return [];
}
}
/**
* 테이블 존재 여부 확인
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result[0]?.exists || false;
} catch (error) {
console.error("테이블 존재 확인 오류:", error);
return false;
}
}
/**
* 특정 컬럼 존재 여부 확인
*/
async checkColumnExists(
tableName: string,
columnName: string
): Promise<boolean> {
try {
const result = await query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
)`,
[tableName, columnName]
);
return result[0]?.exists || false;
} catch (error) {
console.error("컬럼 존재 확인 오류:", error);
return false;
}
}
/**
* 테이블 컬럼 정보 조회 (간단 버전)
*/
private async getTableColumnsSimple(tableName: string): Promise<any[]> {
const result = await query<any>(
`SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
return result;
}
/**
* 컬럼 라벨 조회
*/
private async getColumnLabel(
tableName: string,
columnName: string
): Promise<string | null> {
try {
// column_labels 테이블에서 라벨 조회
const result = await query<{ label_ko: string }>(
`SELECT label_ko
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
return result[0]?.label_ko || null;
} catch (error) {
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
return null;
}
}
/**
* 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회)
*/
async getRecordDetail(
tableName: string,
id: string | number,
enableEntityJoin: boolean = false,
groupByColumns: string[] = []
): Promise<ServiceResponse<any>> {
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[tableName]
);
let pkColumn = "id"; // 기본값
if (pkResult.length > 0) {
pkColumn = pkResult[0].attname;
}
// 🆕 Entity Join이 활성화된 경우
if (enableEntityJoin) {
const { EntityJoinService } = await import("./entityJoinService");
const entityJoinService = new EntityJoinService();
// Entity Join 구성 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length > 0) {
console.log(`✅ Entity Join 감지: ${joinConfigs.length}`);
// Entity Join 쿼리 생성 (개별 파라미터로 전달)
const { query: joinQuery } = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
["*"],
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
);
const result = await pool.query(joinQuery, [id]);
if (result.rows.length === 0) {
return {
success: false,
message: "레코드를 찾을 수 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
}
}
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
if (groupByColumns.length > 0) {
const baseRecord = result.rows[0];
// 그룹핑 컬럼들의 값 추출
const groupConditions: string[] = [];
const groupValues: any[] = [];
let paramIndex = 1;
for (const col of groupByColumns) {
const value = normalizedRows[0][col];
if (value !== undefined && value !== null) {
groupConditions.push(`main."${col}" = $${paramIndex}`);
groupValues.push(value);
paramIndex++;
}
}
if (groupConditions.length > 0) {
const groupWhereClause = groupConditions.join(" AND ");
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
// 그룹핑 기준으로 모든 레코드 조회
const { query: groupQuery } = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
["*"],
groupWhereClause
);
const groupResult = await pool.query(groupQuery, groupValues);
const normalizedGroupRows = normalizeDates(groupResult.rows);
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`);
return {
success: true,
data: normalizedGroupRows, // 🔧 배열로 반환!
};
}
}
return {
success: true,
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
};
}
}
// 기본 쿼리 (Entity Join 없음)
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
const result = await query<any>(queryText, [id]);
if (result.length === 0) {
return {
success: false,
message: "레코드를 찾을 수 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
return {
success: true,
data: result[0],
};
} catch (error) {
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
return {
success: false,
message: "레코드 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 조인된 데이터 조회 (🆕 Entity 조인 지원)
*/
async getJoinedData(
leftTable: string,
rightTable: string,
leftColumn: string,
rightColumn: string,
leftValue?: string | number,
userCompany?: string,
dataFilter?: any, // 🆕 데이터 필터
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
deduplication?: { // 🆕 중복 제거 설정
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): Promise<ServiceResponse<any[]>> {
try {
// 왼쪽 테이블 접근 검증
const leftValidation = await this.validateTableAccess(leftTable);
if (!leftValidation.valid) {
return leftValidation.error!;
}
// 오른쪽 테이블 접근 검증
const rightValidation = await this.validateTableAccess(rightTable);
if (!rightValidation.valid) {
return rightValidation.error!;
}
// 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용
if (enableEntityJoin) {
try {
const { entityJoinService } = await import("./entityJoinService");
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
if (displayColumns && Array.isArray(displayColumns)) {
// 테이블별로 요청된 컬럼들을 그룹핑
const tableColumns: Record<string, Set<string>> = {};
for (const col of displayColumns) {
if (col.name && col.name.includes('.')) {
const [refTable, refColumn] = col.name.split('.');
if (!tableColumns[refTable]) {
tableColumns[refTable] = new Set();
}
tableColumns[refTable].add(refColumn);
}
}
// 각 테이블별로 처리
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
// 이미 조인 설정에 있는지 확인
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
if (existingJoins.length > 0) {
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
for (const refColumn of refColumns) {
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
const existingJoin = existingJoins.find(
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
);
if (!existingJoin) {
// 없으면 새 조인 설정 복제하여 추가
const baseJoin = existingJoins[0];
const newJoin = {
...baseJoin,
displayColumns: [refColumn],
aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size)
// ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴
referenceTable: refTable,
referenceColumn: baseJoin.referenceColumn, // item_number 등
};
joinConfigs.push(newJoin);
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
}
}
} else {
console.warn(`⚠️ 조인 설정 없음: ${refTable}`);
}
}
}
if (joinConfigs.length > 0) {
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
// WHERE 조건 생성
const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 좌측 테이블 조인 조건 (leftValue로 필터링)
// rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002')
if (leftValue !== undefined && leftValue !== null) {
whereConditions.push(`main."${rightColumn}" = $${paramIndex}`);
values.push(leftValue);
paramIndex++;
}
// 회사별 필터링
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
if (hasCompanyCode) {
whereConditions.push(`main.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
}
}
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(`📊 필터 파라미터:`, filterResult.params);
}
}
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
// Entity 조인 쿼리 빌드
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
const selectColumns = ["*"];
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
console.log(`🔍 파라미터:`, values);
const result = await pool.query(finalQuery, values);
// 🔧 날짜 타입 타임존 문제 해결
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
}
}
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
// 🆕 중복 제거 처리
let finalData = normalizedRows;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
finalData = this.deduplicateData(normalizedRows, deduplication);
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`);
}
return {
success: true,
data: finalData,
};
}
} catch (error) {
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
// Entity 조인 실패 시 기본 조인으로 폴백
}
}
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
let queryText = `
SELECT DISTINCT r.*
FROM "${rightTable}" r
INNER JOIN "${leftTable}" l
ON l."${leftColumn}" = r."${rightColumn}"
`;
const values: any[] = [];
const whereConditions: string[] = [];
let paramIndex = 1;
// 좌측 값 필터링
if (leftValue !== undefined && leftValue !== null) {
whereConditions.push(`l."${leftColumn}" = $${paramIndex}`);
values.push(leftValue);
paramIndex++;
}
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
if (hasCompanyCode) {
whereConditions.push(`r.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
}
}
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
}
}
// WHERE 절 추가
if (whereConditions.length > 0) {
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
}
console.log("🔍 조인 쿼리 실행:", queryText);
console.log("📊 조인 쿼리 파라미터:", values);
const result = await query<any>(queryText, values);
// 🆕 중복 제거 처리
let finalData = result;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
finalData = this.deduplicateData(result, deduplication);
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`);
}
return {
success: true,
data: finalData,
};
} catch (error) {
console.error(
`조인 데이터 조회 오류 (${leftTable}${rightTable}):`,
error
);
return {
success: false,
message: "조인 데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 레코드 생성
*/
async createRecord(
tableName: string,
data: Record<string, any>
): Promise<ServiceResponse<any>> {
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
const invalidColumns: string[] = [];
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
}
const columns = Object.keys(filteredData);
const values = Object.values(filteredData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
const queryText = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING *
`;
const result = await query<any>(queryText, values);
return {
success: true,
data: result[0],
};
} catch (error) {
console.error(`레코드 생성 오류 (${tableName}):`, error);
return {
success: false,
message: "레코드 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 레코드 수정
*/
async updateRecord(
tableName: string,
id: string | number,
data: Record<string, any>
): Promise<ServiceResponse<any>> {
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
let cleanData = { ...data };
delete cleanData._relationInfo;
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
const invalidColumns: string[] = [];
cleanData = Object.fromEntries(
Object.entries(cleanData).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
}
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[tableName]
);
let pkColumn = "id";
if (pkResult.length > 0) {
pkColumn = pkResult[0].attname;
}
const columns = Object.keys(cleanData);
const values = Object.values(cleanData);
const setClause = columns
.map((col, index) => `"${col}" = $${index + 1}`)
.join(", ");
const queryText = `
UPDATE "${tableName}"
SET ${setClause}
WHERE "${pkColumn}" = $${values.length + 1}
RETURNING *
`;
values.push(id);
const result = await query<any>(queryText, values);
if (result.length === 0) {
return {
success: false,
message: "레코드를 찾을 수 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) {
console.log("🔗 조인 관계 FK 업데이트:", {
rightTable,
rightColumn,
oldValue: oldLeftValue,
newValue: newLeftValue,
});
try {
const updateRelatedQuery = `
UPDATE "${rightTable}"
SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2
`;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
} catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
}
}
}
return {
success: true,
data: result[0],
};
} catch (error) {
console.error(`레코드 수정 오류 (${tableName}/${id}):`, error);
return {
success: false,
message: "레코드 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 레코드 삭제
*/
async deleteRecord(
tableName: string,
id: string | number | Record<string, any>
): Promise<ServiceResponse<void>> {
try {
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 컬럼 찾기 (복합키 지원)
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY a.attnum`,
[tableName]
);
let whereClauses: string[] = [];
let params: any[] = [];
if (pkResult.length > 1) {
// 복합키인 경우: id가 객체여야 함
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
if (typeof id === 'object' && !Array.isArray(id)) {
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
pkResult.forEach((pk, index) => {
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
params.push(id[pk.attname]);
});
} else {
// id가 문자열/숫자인 경우: 첫 번째 PK만 사용 (하위 호환성)
whereClauses.push(`"${pkResult[0].attname}" = $1`);
params.push(id);
}
} else {
// 단일키인 경우
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
whereClauses.push(`"${pkColumn}" = $1`);
params.push(typeof id === 'object' ? id[pkColumn] : id);
}
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
return {
success: true,
};
} catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error);
return {
success: false,
message: "레코드 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
*/
async deleteGroupRecords(
tableName: string,
filterConditions: Record<string, any>
): Promise<ServiceResponse<{ deleted: number }>> {
try {
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
const whereConditions: string[] = [];
const whereValues: any[] = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value);
paramIndex++;
}
if (whereConditions.length === 0) {
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
}
const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
const result = await pool.query(deleteQuery, whereValues);
console.log(`✅ 그룹 삭제 성공: ${result.rowCount}`);
return { success: true, data: { deleted: result.rowCount || 0 } };
} catch (error) {
console.error("그룹 삭제 오류:", error);
return {
success: false,
message: "그룹 삭제 실패",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 그룹화된 데이터 UPSERT
* - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아
* - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행
* - 각 레코드의 모든 필드 조합을 고유 키로 사용
*/
async upsertGroupedRecords(
tableName: string,
parentKeys: Record<string, any>,
records: Array<Record<string, any>>,
userCompany?: string,
userId?: string
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
try {
// 테이블 접근 권한 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 감지
const pkColumns = await this.getPrimaryKeyColumns(tableName);
if (!pkColumns || pkColumns.length === 0) {
return {
success: false,
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
error: "PRIMARY_KEY_NOT_FOUND",
};
}
const pkColumn = pkColumns[0]; // 첫 번째 PK 사용
console.log(`🔍 UPSERT 시작: ${tableName}`, {
parentKeys,
newRecordsCount: records.length,
primaryKey: pkColumn,
});
// 1. 기존 DB 레코드 조회 (parentKeys 기준)
const whereConditions: string[] = [];
const whereValues: any[] = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(parentKeys)) {
whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value);
paramIndex++;
}
const whereClause = whereConditions.join(" AND ");
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
const existingRecords = await pool.query(selectQuery, whereValues);
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
let inserted = 0;
let updated = 0;
let deleted = 0;
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split('T')[0]; // YYYY-MM-DD 만 추출
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord);
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
});
if (existingRecord) {
// UPDATE: 기존 레코드가 있으면 업데이트
const updateFields: string[] = [];
const updateValues: any[] = [];
let updateParamIndex = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateValues.push(value);
updateParamIndex++;
}
}
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${updateParamIndex}
`;
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
const recordWithMeta: Record<string, any> = {
...fullRecord,
id: uuidv4(), // 새 ID 생성
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(key =>
recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
} else {
insertPlaceholders.push(`$${insertParamIndex}`);
insertValues.push(recordWithMeta[field]);
insertParamIndex++;
}
}
const insertQuery = `
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
VALUES (${insertPlaceholders.join(", ")})
`;
console.log(` INSERT 쿼리:`, { query: insertQuery, values: insertValues });
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
}
}
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
}
return String(existingValue) === String(newValue);
});
});
if (!stillExists) {
// DELETE: 새 레코드에 없으면 삭제
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
}
}
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
return {
success: true,
data: { inserted, updated, deleted },
};
} catch (error) {
console.error(`UPSERT 오류 (${tableName}):`, error);
return {
success: false,
message: "데이터 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
export const dataService = new DataService();