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:
kjs
2025-11-20 10:23:54 +09:00
parent d4895c363c
commit 34cd7ba9e3
13 changed files with 2704 additions and 345 deletions

View File

@@ -14,7 +14,7 @@ router.get(
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } =
req.query;
// 입력값 검증
@@ -37,6 +37,9 @@ router.get(
}
}
// 🆕 enableEntityJoin 파싱
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
// SQL 인젝션 방지를 위한 검증
const tables = [leftTable as string, rightTable as string];
const columns = [leftColumn as string, rightColumn as string];
@@ -64,6 +67,31 @@ router.get(
// 회사 코드 추출 (멀티테넌시 필터링)
const userCompany = req.user?.companyCode;
// displayColumns 파싱 (item_info.item_name 등)
let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined;
if (displayColumns) {
try {
parsedDisplayColumns = JSON.parse(displayColumns as string);
} catch (e) {
console.error("displayColumns 파싱 실패:", e);
}
}
// 🆕 deduplication 파싱
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined;
if (deduplication) {
try {
parsedDeduplication = JSON.parse(deduplication as string);
} catch (e) {
console.error("deduplication 파싱 실패:", e);
}
}
console.log(`🔗 조인 데이터 조회:`, {
leftTable,
rightTable,
@@ -71,10 +99,13 @@ router.get(
rightColumn,
leftValue,
userCompany,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
dataFilter: parsedDataFilter,
enableEntityJoin: enableEntityJoinFlag,
displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그
deduplication: parsedDeduplication, // 🆕 중복 제거 로그
});
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
// 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달)
const result = await dataService.getJoinedData(
leftTable as string,
rightTable as string,
@@ -82,7 +113,10 @@ router.get(
rightColumn as string,
leftValue as string,
userCompany,
parsedDataFilter // 🆕 데이터 필터 전달
parsedDataFilter,
enableEntityJoinFlag,
parsedDisplayColumns, // 🆕 표시 컬럼 전달
parsedDeduplication // 🆕 중복 제거 설정 전달
);
if (!result.success) {
@@ -305,10 +339,31 @@ router.get(
});
}
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
const { enableEntityJoin, groupByColumns } = req.query;
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
// groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분)
let groupByColumnsArray: string[] = [];
if (groupByColumns) {
try {
if (typeof groupByColumns === "string") {
// JSON 형식이면 파싱, 아니면 쉼표로 분리
groupByColumnsArray = groupByColumns.startsWith("[")
? JSON.parse(groupByColumns)
: groupByColumns.split(",").map(c => c.trim());
}
} catch (error) {
console.warn("groupByColumns 파싱 실패:", error);
}
}
// 레코드 상세 조회
const result = await dataService.getRecordDetail(tableName, id);
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag,
groupByColumns: groupByColumnsArray
});
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray);
if (!result.success) {
return res.status(400).json(result);
@@ -523,6 +578,82 @@ router.post(
}
);
/**
* 그룹화된 데이터 UPSERT API
* POST /api/data/upsert-grouped
*
* 요청 본문:
* {
* tableName: string,
* parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" },
* records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ]
* }
*/
router.post(
"/upsert-grouped",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName, parentKeys, records } = req.body;
// 입력값 검증
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
error: "MISSING_PARAMETERS",
});
}
// 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, {
parentKeys,
recordCount: records.length,
});
// UPSERT 수행
const result = await dataService.upsertGroupedRecords(
tableName,
parentKeys,
records
);
if (!result.success) {
return res.status(400).json(result);
}
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
return res.json({
success: true,
message: "데이터가 저장되었습니다.",
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
} catch (error) {
console.error("그룹화된 데이터 UPSERT 오류:", error);
return res.status(500).json({
success: false,
message: "데이터 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
router.delete(
"/:tableName/:id",
authenticateToken,

View File

@@ -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();

View File

@@ -81,18 +81,18 @@ export class EntityJoinService {
let referenceColumn = column.reference_column;
let displayColumn = column.display_column;
if (column.input_type === 'category') {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || 'table_column_category_values';
referenceColumn = referenceColumn || 'value_code';
displayColumn = displayColumn || 'value_label';
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
if (column.input_type === "category") {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || "table_column_category_values";
referenceColumn = referenceColumn || "value_code";
displayColumn = displayColumn || "value_label";
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
@@ -214,8 +214,14 @@ export class EntityJoinService {
): { query: string; aliasMap: Map<string, string> } {
try {
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
// "*"는 특별 처리: AS 없이 그냥 main.*만
const baseColumns = selectColumns
.map((col) => `main.${col}::TEXT AS ${col}`)
.map((col) => {
if (col === "*") {
return "main.*";
}
return `main.${col}::TEXT AS ${col}`;
})
.join(", ");
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
@@ -255,7 +261,9 @@ export class EntityJoinService {
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`);
logger.info(
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`
);
});
const joinColumns = joinConfigs
@@ -266,64 +274,48 @@ export class EntityJoinService {
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
);
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
);
} else {
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
);
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
const concatParts = displayColumns
.map((col) => {
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
// 현재는 dept_info 테이블의 컬럼들을 확인
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
@@ -337,7 +329,7 @@ export class EntityJoinService {
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
@@ -356,13 +348,13 @@ export class EntityJoinService {
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
@@ -424,7 +416,7 @@ export class EntityJoinService {
}
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
@@ -578,13 +570,13 @@ export class EntityJoinService {
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");

View File

@@ -6,9 +6,28 @@
export interface ColumnFilter {
id: string;
columnName: string;
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
operator:
| "equals"
| "not_equals"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with"
| "is_null"
| "is_not_null"
| "greater_than"
| "less_than"
| "greater_than_or_equal"
| "less_than_or_equal"
| "between"
| "date_range_contains";
value: string | string[];
valueType: "static" | "category" | "code";
valueType: "static" | "category" | "code" | "dynamic";
rangeConfig?: {
startColumn: string;
endColumn: string;
};
}
export interface DataFilterConfig {
@@ -123,6 +142,71 @@ export function buildDataFilterWhereClause(
conditions.push(`${columnRef} IS NOT NULL`);
break;
case "greater_than":
conditions.push(`${columnRef} > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "less_than":
conditions.push(`${columnRef} < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "greater_than_or_equal":
conditions.push(`${columnRef} >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "less_than_or_equal":
conditions.push(`${columnRef} <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "between":
if (Array.isArray(value) && value.length === 2) {
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
params.push(value[0], value[1]);
paramIndex += 2;
}
break;
case "date_range_contains":
// 날짜 범위 포함: start_date <= value <= end_date
// filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" }
// NULL 처리:
// - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속)
// - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속)
// - 둘 다 있으면: start_date <= value <= end_date
if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) {
const startCol = getColumnRef(filter.rangeConfig.startColumn);
const endCol = getColumnRef(filter.rangeConfig.endColumn);
// value가 "TODAY"면 현재 날짜로 변환
const actualValue = filter.valueType === "dynamic" && value === "TODAY"
? "CURRENT_DATE"
: `$${paramIndex}`;
if (actualValue === "CURRENT_DATE") {
// CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함
// NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE)
conditions.push(
`((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))`
);
} else {
// NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param)
conditions.push(
`((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))`
);
params.push(value);
paramIndex++;
}
}
break;
default:
// 알 수 없는 연산자는 무시
break;