feat: 수정 모드 UPSERT 기능 구현
- SelectedItemsDetailInput 컴포넌트 수정 모드 지원 - 그룹화된 데이터 UPSERT API 추가 (/api/data/upsert-grouped) - 부모 키 기준으로 기존 레코드 조회 후 INSERT/UPDATE/DELETE - 각 레코드의 모든 필드 조합을 고유 키로 사용 - created_date 보존 (UPDATE 시) - 수정 모드에서 groupByColumns 기준으로 관련 레코드 조회 - 날짜 타입 ISO 형식 자동 감지 및 포맷팅 (YYYY.MM.DD) 주요 변경사항: - backend: dataService.upsertGroupedRecords() 메서드 구현 - backend: dataRoutes POST /api/data/upsert-grouped 엔드포인트 추가 - frontend: ScreenModal에서 groupByColumns 파라미터 전달 - frontend: SelectedItemsDetailInput 수정 모드 로직 추가 - frontend: 날짜 필드 타임존 제거 및 포맷팅 개선
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||
*/
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
|
||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||
|
||||
interface GetTableDataParams {
|
||||
@@ -53,6 +54,103 @@ const BLOCKED_TABLES = [
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 접근 검증 (공통 메서드)
|
||||
*/
|
||||
@@ -374,11 +472,13 @@ class DataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회
|
||||
* 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회)
|
||||
*/
|
||||
async getRecordDetail(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false,
|
||||
groupByColumns: string[] = []
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블 접근 검증
|
||||
@@ -401,6 +501,87 @@ class DataService {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[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 = baseRecord[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);
|
||||
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: groupResult.rows, // 🔧 배열로 반환!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0], // 그룹핑 없으면 단일 레코드
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 쿼리 (Entity Join 없음)
|
||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
const result = await query<any>(queryText, [id]);
|
||||
|
||||
@@ -427,7 +608,7 @@ class DataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
* 조인된 데이터 조회 (🆕 Entity 조인 지원)
|
||||
*/
|
||||
async getJoinedData(
|
||||
leftTable: string,
|
||||
@@ -436,7 +617,15 @@ class DataService {
|
||||
rightColumn: string,
|
||||
leftValue?: string | number,
|
||||
userCompany?: string,
|
||||
dataFilter?: any // 🆕 데이터 필터
|
||||
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 {
|
||||
// 왼쪽 테이블 접근 검증
|
||||
@@ -451,6 +640,143 @@ class DataService {
|
||||
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);
|
||||
|
||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`);
|
||||
|
||||
// 🆕 중복 제거 처리
|
||||
let finalData = result.rows;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(result.rows, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: finalData,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
|
||||
// Entity 조인 실패 시 기본 조인으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
|
||||
let queryText = `
|
||||
SELECT DISTINCT r.*
|
||||
FROM "${rightTable}" r
|
||||
@@ -501,9 +827,17 @@ class DataService {
|
||||
|
||||
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: result,
|
||||
data: finalData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -728,6 +1062,185 @@ class DataService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT
|
||||
* - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아
|
||||
* - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행
|
||||
* - 각 레코드의 모든 필드 조합을 고유 키로 사용
|
||||
*/
|
||||
async upsertGroupedRecords(
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>
|
||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
if (!this.canAccessTable(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'에 접근할 수 없습니다.`,
|
||||
error: "ACCESS_DENIED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 감지
|
||||
const pkColumn = await this.detectPrimaryKey(tableName);
|
||||
if (!pkColumn) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
|
||||
error: "PRIMARY_KEY_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 새 레코드 처리 (INSERT or UPDATE)
|
||||
for (const newRecord of records) {
|
||||
// 전체 레코드 데이터 (parentKeys + newRecord)
|
||||
const fullRecord = { ...parentKeys, ...newRecord };
|
||||
|
||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||
const uniqueFields = Object.keys(newRecord);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = newRecord[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: 기존 레코드가 없으면 삽입
|
||||
const insertFields = Object.keys(fullRecord);
|
||||
const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`);
|
||||
const insertValues = Object.values(fullRecord);
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${insertFields.map(f => `"${f}"`).join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user