1594 lines
51 KiB
TypeScript
1594 lines
51 KiB
TypeScript
/**
|
||
* 동적 데이터 서비스
|
||
*
|
||
* 주요 특징:
|
||
* 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]
|
||
);
|
||
|
||
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
||
pkColumns: pkResult.map((r) => r.attname),
|
||
pkCount: pkResult.length,
|
||
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
||
inputIdType: typeof id,
|
||
});
|
||
|
||
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 ")} RETURNING *`;
|
||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||
|
||
const result = await query<any>(queryText, params);
|
||
|
||
// 삭제된 행이 없으면 실패 처리
|
||
if (result.length === 0) {
|
||
console.warn(
|
||
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
||
{ whereClauses, params }
|
||
);
|
||
return {
|
||
success: false,
|
||
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
||
error: "RECORD_NOT_FOUND",
|
||
};
|
||
}
|
||
|
||
console.log(
|
||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||
);
|
||
|
||
return {
|
||
success: true,
|
||
data: result[0], // 삭제된 레코드 정보 반환
|
||
};
|
||
} catch (error) {
|
||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||
return {
|
||
success: false,
|
||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||
error: error instanceof Error ? error.message : "Unknown error",
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||
* @param tableName 테이블명
|
||
* @param filterConditions 삭제 조건
|
||
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||
*/
|
||
async deleteGroupRecords(
|
||
tableName: string,
|
||
filterConditions: Record<string, any>,
|
||
userCompany?: string
|
||
): 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",
|
||
};
|
||
}
|
||
|
||
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
|
||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||
if (hasCompanyCode && userCompany && userCompany !== "*") {
|
||
whereConditions.push(`"company_code" = $${paramIndex}`);
|
||
whereValues.push(userCompany);
|
||
paramIndex++;
|
||
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
|
||
}
|
||
|
||
const whereClause = whereConditions.join(" AND ");
|
||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||
|
||
console.log(`🗑️ 그룹 삭제:`, {
|
||
tableName,
|
||
conditions: filterConditions,
|
||
userCompany,
|
||
whereClause,
|
||
});
|
||
|
||
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)
|
||
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||
const recordWithMeta: Record<string, any> = {
|
||
...recordWithoutCreatedDate,
|
||
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();
|