Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal
This commit is contained in:
@@ -65,6 +65,13 @@ export class AdminService {
|
||||
}
|
||||
);
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
|
||||
/* [원본 코드 - 권한 그룹 체크]
|
||||
if (userType === "COMPANY_ADMIN") {
|
||||
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||
if (userRoleGroups.length > 0) {
|
||||
@@ -141,6 +148,7 @@ export class AdminService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
} else if (
|
||||
menuType !== undefined &&
|
||||
userType === "SUPER_ADMIN" &&
|
||||
@@ -412,6 +420,15 @@ export class AdminService {
|
||||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
|
||||
if (userType === "SUPER_ADMIN") {
|
||||
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||
@@ -471,6 +488,7 @@ export class AdminService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 2. 회사별 필터링 조건 생성
|
||||
let companyFilter = "";
|
||||
|
||||
@@ -1189,6 +1189,13 @@ class DataService {
|
||||
[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[] = [];
|
||||
|
||||
@@ -1216,17 +1223,31 @@ class DataService {
|
||||
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||
}
|
||||
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
||||
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);
|
||||
@@ -1240,10 +1261,14 @@ class DataService {
|
||||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
* @param tableName 테이블명
|
||||
* @param filterConditions 삭제 조건
|
||||
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||||
*/
|
||||
async deleteGroupRecords(
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>
|
||||
filterConditions: Record<string, any>,
|
||||
userCompany?: string
|
||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||
try {
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
@@ -1255,6 +1280,7 @@ class DataService {
|
||||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 사용자 필터 조건 추가
|
||||
for (const [key, value] of Object.entries(filterConditions)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
@@ -1269,10 +1295,24 @@ class DataService {
|
||||
};
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 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 });
|
||||
console.log(`🗑️ 그룹 삭제:`, {
|
||||
tableName,
|
||||
conditions: filterConditions,
|
||||
userCompany,
|
||||
whereClause,
|
||||
});
|
||||
|
||||
const result = await pool.query(deleteQuery, whereValues);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
import tableCategoryValueService from "./tableCategoryValueService";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
@@ -427,6 +428,24 @@ export class DynamicFormService {
|
||||
dataToInsert,
|
||||
});
|
||||
|
||||
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
|
||||
const companyCodeForCategory = company_code || "*";
|
||||
const { convertedData: categoryConvertedData, conversions } =
|
||||
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
|
||||
tableName,
|
||||
companyCodeForCategory,
|
||||
dataToInsert
|
||||
);
|
||||
|
||||
if (conversions.length > 0) {
|
||||
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
|
||||
// 변환된 데이터로 교체
|
||||
Object.assign(dataToInsert, categoryConvertedData);
|
||||
} else {
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||||
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||
@@ -1173,12 +1192,18 @@ export class DynamicFormService {
|
||||
|
||||
/**
|
||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||
* @param id 삭제할 레코드 ID
|
||||
* @param tableName 테이블명
|
||||
* @param companyCode 회사 코드
|
||||
* @param userId 사용자 ID
|
||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||
*/
|
||||
async deleteFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
companyCode?: string,
|
||||
userId?: string
|
||||
userId?: string,
|
||||
screenId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
@@ -1291,14 +1316,19 @@ export class DynamicFormService {
|
||||
const recordCompanyCode =
|
||||
deletedRecord?.company_code || companyCode || "*";
|
||||
|
||||
await this.executeDataflowControlIfConfigured(
|
||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||
tableName,
|
||||
deletedRecord,
|
||||
"delete",
|
||||
userId || "system",
|
||||
recordCompanyCode
|
||||
);
|
||||
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||
if (screenId && screenId > 0) {
|
||||
await this.executeDataflowControlIfConfigured(
|
||||
screenId,
|
||||
tableName,
|
||||
deletedRecord,
|
||||
"delete",
|
||||
userId || "system",
|
||||
recordCompanyCode
|
||||
);
|
||||
} else {
|
||||
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
||||
}
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||
@@ -1643,10 +1673,16 @@ export class DynamicFormService {
|
||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||
});
|
||||
|
||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
||||
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||
const buttonActionType = properties?.componentConfig?.action?.type;
|
||||
const isMatchingAction =
|
||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||
|
||||
if (
|
||||
properties?.componentType === "button-primary" &&
|
||||
properties?.componentConfig?.action?.type === "save" &&
|
||||
isMatchingAction &&
|
||||
properties?.webTypeConfig?.enableDataflowControl === true
|
||||
) {
|
||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||
|
||||
283
backend-node/src/services/excelMappingService.ts
Normal file
283
backend-node/src/services/excelMappingService.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface ExcelMappingTemplate {
|
||||
id?: number;
|
||||
tableName: string;
|
||||
excelColumns: string[];
|
||||
excelColumnsHash: string;
|
||||
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
|
||||
companyCode: string;
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
class ExcelMappingService {
|
||||
/**
|
||||
* 엑셀 컬럼 목록으로 해시 생성
|
||||
* 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
|
||||
*/
|
||||
generateColumnsHash(columns: string[]): string {
|
||||
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
|
||||
const sortedColumns = [...columns].sort();
|
||||
const columnsString = sortedColumns.join("|");
|
||||
return crypto.createHash("md5").update(columnsString).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* 동일한 컬럼 구조가 있으면 기존 매핑 반환
|
||||
*/
|
||||
async findMappingByColumns(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate | null> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
ORDER BY
|
||||
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
|
||||
updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
logger.info("기존 매핑 템플릿 발견", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
});
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
|
||||
*/
|
||||
async saveMappingTemplate(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
columnMappings: Record<string, string | null>,
|
||||
companyCode: string,
|
||||
userId?: string
|
||||
): Promise<ExcelMappingTemplate> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
INSERT INTO excel_mapping_template (
|
||||
table_name,
|
||||
excel_columns,
|
||||
excel_columns_hash,
|
||||
column_mappings,
|
||||
company_code,
|
||||
created_date,
|
||||
updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (table_name, excel_columns_hash, company_code)
|
||||
DO UPDATE SET
|
||||
column_mappings = EXCLUDED.column_mappings,
|
||||
updated_date = NOW()
|
||||
RETURNING
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
JSON.stringify(columnMappings),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("매핑 템플릿 저장 완료", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
hash,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 매핑 템플릿 조회
|
||||
*/
|
||||
async getMappingTemplates(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate[]> {
|
||||
try {
|
||||
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
*/
|
||||
async deleteMappingTemplate(
|
||||
id: number,
|
||||
companyCode: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info("매핑 템플릿 삭제", { id, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
logger.info("매핑 템플릿 삭제 완료", { id });
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExcelMappingService();
|
||||
|
||||
908
backend-node/src/services/masterDetailExcelService.ts
Normal file
908
backend-node/src/services/masterDetailExcelService.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
/**
|
||||
* 마스터-디테일 엑셀 처리 서비스
|
||||
*
|
||||
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||||
*/
|
||||
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ================================
|
||||
// 인터페이스 정의
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보
|
||||
*/
|
||||
export interface MasterDetailRelation {
|
||||
masterTable: string;
|
||||
detailTable: string;
|
||||
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||||
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||||
masterColumns: ColumnInfo[];
|
||||
detailColumns: ColumnInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 정보
|
||||
*/
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
inputType: string;
|
||||
isFromMaster: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정
|
||||
*/
|
||||
export interface SplitPanelConfig {
|
||||
leftPanel: {
|
||||
tableName: string;
|
||||
columns: Array<{ name: string; label: string; width?: number }>;
|
||||
};
|
||||
rightPanel: {
|
||||
tableName: string;
|
||||
columns: Array<{ name: string; label: string; width?: number }>;
|
||||
relation?: {
|
||||
type: string;
|
||||
foreignKey?: string;
|
||||
leftColumn?: string;
|
||||
// 복합키 지원 (새로운 방식)
|
||||
keys?: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 결과
|
||||
*/
|
||||
export interface ExcelDownloadData {
|
||||
headers: string[]; // 컬럼 라벨들
|
||||
columns: string[]; // 컬럼명들
|
||||
data: Record<string, any>[];
|
||||
masterColumns: string[]; // 마스터 컬럼 목록
|
||||
detailColumns: string[]; // 디테일 컬럼 목록
|
||||
joinKey: string; // 조인 키
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 결과
|
||||
*/
|
||||
export interface ExcelUploadResult {
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
masterUpdated: number;
|
||||
detailInserted: number;
|
||||
detailDeleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ================================
|
||||
// 서비스 클래스
|
||||
// ================================
|
||||
|
||||
class MasterDetailExcelService {
|
||||
|
||||
/**
|
||||
* 화면 ID로 분할 패널 설정 조회
|
||||
*/
|
||||
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||||
try {
|
||||
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||||
|
||||
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||||
const result = await queryOne<any>(
|
||||
`SELECT properties->>'componentConfig' as config
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties->>'componentType' = 'split-panel-layout'
|
||||
LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
if (!result || !result.config) {
|
||||
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = typeof result.config === "string"
|
||||
? JSON.parse(result.config)
|
||||
: result.config;
|
||||
|
||||
logger.info(`분할 패널 설정 발견:`, {
|
||||
leftTable: config.leftPanel?.tableName,
|
||||
rightTable: config.rightPanel?.tableName,
|
||||
relation: config.rightPanel?.relation,
|
||||
});
|
||||
|
||||
return {
|
||||
leftPanel: config.leftPanel,
|
||||
rightPanel: config.rightPanel,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* column_labels에서 Entity 관계 정보 조회
|
||||
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||||
*/
|
||||
async getEntityRelation(
|
||||
detailTable: string,
|
||||
masterTable: string
|
||||
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||||
try {
|
||||
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||||
|
||||
const result = await queryOne<any>(
|
||||
`SELECT column_name, reference_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'entity'
|
||||
AND reference_table = $2
|
||||
LIMIT 1`,
|
||||
[detailTable, masterTable]
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||||
|
||||
return {
|
||||
detailFkColumn: result.column_name,
|
||||
masterKeyColumn: result.reference_column,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 컬럼 라벨 정보 조회
|
||||
*/
|
||||
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const labelMap = new Map<string, string>();
|
||||
for (const row of result) {
|
||||
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||||
}
|
||||
|
||||
return labelMap;
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보 조합
|
||||
*/
|
||||
async getMasterDetailRelation(
|
||||
screenId: number
|
||||
): Promise<MasterDetailRelation | null> {
|
||||
try {
|
||||
// 1. 분할 패널 설정 조회
|
||||
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||||
if (!splitPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const masterTable = splitPanel.leftPanel.tableName;
|
||||
const detailTable = splitPanel.rightPanel.tableName;
|
||||
|
||||
if (!masterTable || !detailTable) {
|
||||
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
||||
let masterKeyColumn: string | undefined;
|
||||
let detailFkColumn: string | undefined;
|
||||
|
||||
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
||||
if (relationKeys && relationKeys.length > 0) {
|
||||
// keys 배열에서 첫 번째 키 사용
|
||||
masterKeyColumn = relationKeys[0].leftColumn;
|
||||
detailFkColumn = relationKeys[0].rightColumn;
|
||||
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
||||
} else {
|
||||
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
||||
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||
}
|
||||
|
||||
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||
if (!masterKeyColumn || !detailFkColumn) {
|
||||
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||||
if (entityRelation) {
|
||||
masterKeyColumn = entityRelation.masterKeyColumn;
|
||||
detailFkColumn = entityRelation.detailFkColumn;
|
||||
}
|
||||
}
|
||||
|
||||
if (!masterKeyColumn || !detailFkColumn) {
|
||||
logger.warn("조인 키 정보를 찾을 수 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 컬럼 라벨 정보 조회
|
||||
const masterLabels = await this.getColumnLabels(masterTable);
|
||||
const detailLabels = await this.getColumnLabels(detailTable);
|
||||
|
||||
// 5. 마스터 컬럼 정보 구성
|
||||
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||||
name: col.name,
|
||||
label: masterLabels.get(col.name) || col.label || col.name,
|
||||
inputType: "text",
|
||||
isFromMaster: true,
|
||||
}));
|
||||
|
||||
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||||
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||||
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||||
.map(col => ({
|
||||
name: col.name,
|
||||
label: detailLabels.get(col.name) || col.label || col.name,
|
||||
inputType: "text",
|
||||
isFromMaster: false,
|
||||
}));
|
||||
|
||||
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||||
masterTable,
|
||||
detailTable,
|
||||
masterKeyColumn,
|
||||
detailFkColumn,
|
||||
masterColumnCount: masterColumns.length,
|
||||
detailColumnCount: detailColumns.length,
|
||||
});
|
||||
|
||||
return {
|
||||
masterTable,
|
||||
detailTable,
|
||||
masterKeyColumn,
|
||||
detailFkColumn,
|
||||
masterColumns,
|
||||
detailColumns,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||||
*/
|
||||
async getJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
companyCode: string,
|
||||
filters?: Record<string, any>
|
||||
): Promise<ExcelDownloadData> {
|
||||
try {
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 조인 컬럼과 일반 컬럼 분리
|
||||
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
||||
const entityJoins: Array<{
|
||||
refTable: string;
|
||||
refColumn: string;
|
||||
sourceColumn: string;
|
||||
alias: string;
|
||||
displayColumn: string;
|
||||
}> = [];
|
||||
|
||||
// SELECT 절 구성
|
||||
const selectParts: string[] = [];
|
||||
let aliasIndex = 0;
|
||||
|
||||
// 마스터 컬럼 처리
|
||||
for (const col of masterColumns) {
|
||||
if (col.name.includes(".")) {
|
||||
// 조인 컬럼: 테이블명.컬럼명
|
||||
const [refTable, displayColumn] = col.name.split(".");
|
||||
const alias = `ej${aliasIndex++}`;
|
||||
|
||||
// column_labels에서 FK 컬럼 찾기
|
||||
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
||||
if (fkColumn) {
|
||||
entityJoins.push({
|
||||
refTable,
|
||||
refColumn: fkColumn.referenceColumn,
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
// FK를 못 찾으면 NULL로 처리
|
||||
selectParts.push(`NULL AS "${col.name}"`);
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼
|
||||
selectParts.push(`m."${col.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 디테일 컬럼 처리
|
||||
for (const col of detailColumns) {
|
||||
if (col.name.includes(".")) {
|
||||
// 조인 컬럼: 테이블명.컬럼명
|
||||
const [refTable, displayColumn] = col.name.split(".");
|
||||
const alias = `ej${aliasIndex++}`;
|
||||
|
||||
// column_labels에서 FK 컬럼 찾기
|
||||
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
||||
if (fkColumn) {
|
||||
entityJoins.push({
|
||||
refTable,
|
||||
refColumn: fkColumn.referenceColumn,
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
selectParts.push(`NULL AS "${col.name}"`);
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼
|
||||
selectParts.push(`d."${col.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClause = selectParts.join(", ");
|
||||
|
||||
// 엔티티 조인 절 구성
|
||||
const entityJoinClauses = entityJoins.map(ej =>
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
).join("\n ");
|
||||
|
||||
// WHERE 절 구성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (최고 관리자 제외)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 추가 필터 적용
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
// 조인 컬럼인지 확인
|
||||
if (key.includes(".")) continue;
|
||||
// 마스터 테이블 컬럼인지 확인
|
||||
const isMasterCol = masterColumns.some(c => c.name === key);
|
||||
const tableAlias = isMasterCol ? "m" : "d";
|
||||
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
FROM "${masterTable}" m
|
||||
LEFT JOIN "${detailTable}" d
|
||||
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
|
||||
const data = await query<any>(sql, params);
|
||||
|
||||
// 헤더 및 컬럼 정보 구성
|
||||
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||||
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||||
|
||||
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||||
|
||||
return {
|
||||
headers,
|
||||
columns,
|
||||
data,
|
||||
masterColumns: masterColumns.map(c => c.name),
|
||||
detailColumns: detailColumns.map(c => c.name),
|
||||
joinKey: masterKeyColumn,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
||||
*/
|
||||
private async findForeignKeyColumn(
|
||||
sourceTable: string,
|
||||
referenceTable: string
|
||||
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
||||
try {
|
||||
const result = await query<{ column_name: string; reference_column: string }>(
|
||||
`SELECT column_name, reference_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND reference_table = $2
|
||||
AND input_type = 'entity'
|
||||
LIMIT 1`,
|
||||
[sourceTable, referenceTable]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
return {
|
||||
sourceColumn: result[0].column_name,
|
||||
referenceColumn: result[0].reference_column,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
data: Record<string, any>[],
|
||||
companyCode: string,
|
||||
userId?: string
|
||||
): Promise<ExcelUploadResult> {
|
||||
const result: ExcelUploadResult = {
|
||||
success: false,
|
||||
masterInserted: 0,
|
||||
masterUpdated: 0,
|
||||
detailInserted: 0,
|
||||
detailDeleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
|
||||
// 2. 각 그룹 처리
|
||||
for (const [masterKey, rows] of groupedData.entries()) {
|
||||
try {
|
||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||
const masterData: Record<string, any> = {};
|
||||
for (const col of masterColumns) {
|
||||
if (rows[0][col.name] !== undefined) {
|
||||
masterData[col.name] = rows[0][col.name];
|
||||
}
|
||||
}
|
||||
|
||||
// 회사 코드, 작성자 추가
|
||||
masterData.company_code = companyCode;
|
||||
if (userId) {
|
||||
masterData.writer = userId;
|
||||
}
|
||||
|
||||
// 2b. 마스터 UPSERT
|
||||
const existingMaster = await client.query(
|
||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// INSERT
|
||||
const insertCols = Object.keys(masterData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
// 2c. 기존 디테일 삭제
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
|
||||
// 2d. 새 디테일 INSERT
|
||||
for (const row of rows) {
|
||||
const detailData: Record<string, any> = {};
|
||||
|
||||
// FK 컬럼 추가
|
||||
detailData[detailFkColumn] = masterKey;
|
||||
detailData.company_code = companyCode;
|
||||
if (userId) {
|
||||
detailData.writer = userId;
|
||||
}
|
||||
|
||||
// 디테일 컬럼 데이터 추출
|
||||
for (const col of detailColumns) {
|
||||
if (row[col.name] !== undefined) {
|
||||
detailData[col.name] = row[col.name];
|
||||
}
|
||||
}
|
||||
|
||||
const insertCols = Object.keys(detailData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => detailData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.detailInserted++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||||
|
||||
logger.info(`마스터-디테일 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailDeleted: result.detailDeleted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 간단 모드 업로드
|
||||
*
|
||||
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
||||
* 채번 규칙을 통해 마스터 키 자동 생성
|
||||
*
|
||||
* @param screenId 화면 ID
|
||||
* @param detailData 디테일 데이터 배열
|
||||
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||
* @param companyCode 회사 코드
|
||||
* @param userId 사용자 ID
|
||||
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
||||
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
||||
*/
|
||||
async uploadSimple(
|
||||
screenId: number,
|
||||
detailData: Record<string, any>[],
|
||||
masterFieldValues: Record<string, any>,
|
||||
numberingRuleId: string | undefined,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
afterUploadFlowId?: string,
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
detailInserted: number;
|
||||
generatedKey: string;
|
||||
errors: string[];
|
||||
controlResult?: any;
|
||||
}> {
|
||||
const result: {
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
detailInserted: number;
|
||||
generatedKey: string;
|
||||
errors: string[];
|
||||
controlResult?: any;
|
||||
} = {
|
||||
success: false,
|
||||
masterInserted: 0,
|
||||
detailInserted: 0,
|
||||
generatedKey: "",
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 마스터-디테일 관계 정보 조회
|
||||
const relation = await this.getMasterDetailRelation(screenId);
|
||||
if (!relation) {
|
||||
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
||||
|
||||
// 2. 채번 처리
|
||||
let generatedKey: string;
|
||||
|
||||
if (numberingRuleId) {
|
||||
// 채번 규칙으로 키 생성
|
||||
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
||||
} else {
|
||||
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
||||
generatedKey = masterFieldValues[masterKeyColumn];
|
||||
if (!generatedKey) {
|
||||
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
result.generatedKey = generatedKey;
|
||||
logger.info(`채번 결과: ${generatedKey}`);
|
||||
|
||||
// 3. 마스터 레코드 생성
|
||||
const masterData: Record<string, any> = {
|
||||
...masterFieldValues,
|
||||
[masterKeyColumn]: generatedKey,
|
||||
company_code: companyCode,
|
||||
writer: userId,
|
||||
};
|
||||
|
||||
// 마스터 컬럼명 목록 구성
|
||||
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
||||
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
||||
const masterValues = masterCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
||||
masterValues
|
||||
);
|
||||
result.masterInserted = 1;
|
||||
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||
|
||||
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
||||
const insertedDetailRows: Record<string, any>[] = [];
|
||||
|
||||
for (const row of detailData) {
|
||||
try {
|
||||
const detailRowData: Record<string, any> = {
|
||||
...row,
|
||||
[detailFkColumn]: generatedKey,
|
||||
company_code: companyCode,
|
||||
writer: userId,
|
||||
};
|
||||
|
||||
// 빈 값 필터링 및 id 제외
|
||||
const detailCols = Object.keys(detailRowData).filter(k =>
|
||||
k !== "id" &&
|
||||
detailRowData[k] !== undefined &&
|
||||
detailRowData[k] !== null &&
|
||||
detailRowData[k] !== ""
|
||||
);
|
||||
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||
|
||||
// RETURNING *로 삽입된 데이터 반환받기
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
||||
RETURNING *`,
|
||||
detailValues
|
||||
);
|
||||
|
||||
if (insertResult.rows && insertResult.rows[0]) {
|
||||
insertedDetailRows.push(insertResult.rows[0]);
|
||||
}
|
||||
|
||||
result.detailInserted++;
|
||||
} catch (error: any) {
|
||||
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||
logger.error(`디테일 행 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
||||
|
||||
await client.query("COMMIT");
|
||||
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||
|
||||
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
detailInserted: result.detailInserted,
|
||||
generatedKey: result.generatedKey,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
// 업로드 후 제어 실행 (단일 또는 다중)
|
||||
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
||||
? afterUploadFlows // 다중 제어
|
||||
: afterUploadFlowId
|
||||
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
||||
: [];
|
||||
|
||||
if (flowsToExecute.length > 0 && result.success) {
|
||||
try {
|
||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||
|
||||
// 마스터 데이터 구성
|
||||
const masterData = {
|
||||
...masterFieldValues,
|
||||
[relation!.masterKeyColumn]: result.generatedKey,
|
||||
company_code: companyCode,
|
||||
};
|
||||
|
||||
const controlResults: any[] = [];
|
||||
|
||||
// 순서대로 제어 실행
|
||||
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
||||
|
||||
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
||||
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
||||
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
||||
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flow.flowId),
|
||||
{
|
||||
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
||||
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
||||
buttonId: "excel-upload-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: masterData,
|
||||
// 추가 컨텍스트: 마스터/디테일 정보
|
||||
masterData: masterData,
|
||||
detailData: insertedDetailRows,
|
||||
masterTable: relation!.masterTable,
|
||||
detailTable: relation!.detailTable,
|
||||
masterKeyColumn: relation!.masterKeyColumn,
|
||||
detailFkColumn: relation!.detailFkColumn,
|
||||
}
|
||||
);
|
||||
|
||||
controlResults.push({
|
||||
flowId: flow.flowId,
|
||||
order: flow.order,
|
||||
success: controlResult.success,
|
||||
message: controlResult.message,
|
||||
executedNodes: controlResult.nodes?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
result.controlResult = {
|
||||
success: controlResults.every(r => r.success),
|
||||
executedFlows: controlResults.length,
|
||||
results: controlResults,
|
||||
};
|
||||
|
||||
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
||||
} catch (controlError: any) {
|
||||
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
||||
result.controlResult = {
|
||||
success: false,
|
||||
message: `제어 실행 실패: ${controlError.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||
*/
|
||||
private async generateNumberWithRule(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
|
||||
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||
|
||||
return generatedCode;
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const masterDetailExcelService = new MasterDetailExcelService();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
|
||||
const insertedData = { ...data };
|
||||
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
|
||||
// 🔥 채번 규칙 서비스 동적 import
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
|
||||
for (const mapping of fieldMappings) {
|
||||
fields.push(mapping.targetField);
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
let value: any;
|
||||
|
||||
// 🔥 값 생성 유형에 따른 처리
|
||||
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||
|
||||
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||
// 자동 생성 (채번 규칙)
|
||||
const companyCode = context.buttonContext?.companyCode || "*";
|
||||
try {
|
||||
value = await numberingRuleService.allocateCode(
|
||||
mapping.numberingRuleId,
|
||||
companyCode
|
||||
);
|
||||
console.log(
|
||||
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||
console.error(
|
||||
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||
);
|
||||
throw new Error(
|
||||
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||
);
|
||||
}
|
||||
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||
// 고정값
|
||||
value = mapping.staticValue;
|
||||
console.log(
|
||||
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||
);
|
||||
} else {
|
||||
// 소스 필드
|
||||
value = data[mapping.sourceField];
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
}
|
||||
|
||||
values.push(value);
|
||||
|
||||
// 🔥 삽입된 값을 데이터에 반영
|
||||
insertedData[mapping.targetField] = value;
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||
const hasWriterMapping = fieldMappings.some(
|
||||
@@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
|
||||
}
|
||||
});
|
||||
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||
let finalWhereConditions: any[];
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||
finalWhereConditions = whereConditions;
|
||||
} else {
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
}
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
enhancedWhereConditions,
|
||||
finalWhereConditions,
|
||||
data,
|
||||
paramIndex
|
||||
);
|
||||
@@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
|
||||
return deletedDataArray;
|
||||
}
|
||||
|
||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
||||
// 🆕 context-data 모드: 개별 삭제
|
||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||
|
||||
for (const data of dataArray) {
|
||||
console.log("🔍 WHERE 조건 처리 중...");
|
||||
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||
let finalWhereConditions: any[];
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||
finalWhereConditions = whereConditions;
|
||||
} else {
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
}
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
enhancedWhereConditions,
|
||||
finalWhereConditions,
|
||||
data,
|
||||
1
|
||||
);
|
||||
@@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService {
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE ${updateWhereConditions}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
logger.info(`🔄 UPDATE 실행:`, {
|
||||
@@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService {
|
||||
values: updateValues,
|
||||
});
|
||||
|
||||
await txClient.query(updateSql, updateValues);
|
||||
const updateResult = await txClient.query(updateSql, updateValues);
|
||||
updatedCount++;
|
||||
|
||||
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||
if (updateResult.rows && updateResult.rows[0]) {
|
||||
Object.assign(data, updateResult.rows[0]);
|
||||
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
|
||||
}
|
||||
} else {
|
||||
// 3-B. 없으면 INSERT
|
||||
const columns: string[] = [];
|
||||
@@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService {
|
||||
const insertSql = `
|
||||
INSERT INTO ${targetTable} (${columns.join(", ")})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
logger.info(`➕ INSERT 실행:`, {
|
||||
@@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService {
|
||||
conflictKeyValues,
|
||||
});
|
||||
|
||||
await txClient.query(insertSql, values);
|
||||
const insertResult = await txClient.query(insertSql, values);
|
||||
insertedCount++;
|
||||
|
||||
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||
if (insertResult.rows && insertResult.rows[0]) {
|
||||
Object.assign(data, insertResult.rows[0]);
|
||||
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService {
|
||||
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
|
||||
);
|
||||
|
||||
return {
|
||||
insertedCount,
|
||||
updatedCount,
|
||||
totalCount: insertedCount + updatedCount,
|
||||
};
|
||||
// 🔥 다음 노드에 전달할 데이터 반환
|
||||
// dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
|
||||
// 카운트 정보도 함께 반환하여 기존 호환성 유지
|
||||
return dataArray;
|
||||
};
|
||||
|
||||
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
||||
@@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService {
|
||||
const trueData: any[] = [];
|
||||
const falseData: any[] = [];
|
||||
|
||||
inputData.forEach((item: any) => {
|
||||
const results = conditions.map((condition: any) => {
|
||||
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||
for (const item of inputData) {
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
const fieldValue = item[condition.field];
|
||||
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
// EXISTS 계열 연산자 처리
|
||||
if (
|
||||
condition.operator === "EXISTS_IN" ||
|
||||
condition.operator === "NOT_EXISTS_IN"
|
||||
) {
|
||||
const existsResult = await this.evaluateExistsCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
condition.lookupTable,
|
||||
condition.lookupField,
|
||||
context.buttonContext?.companyCode
|
||||
);
|
||||
results.push(existsResult);
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
// 일반 연산자 처리
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
|
||||
results.push(
|
||||
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||
);
|
||||
}
|
||||
|
||||
return this.evaluateCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
compareValue
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const result =
|
||||
logic === "OR"
|
||||
@@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService {
|
||||
} else {
|
||||
falseData.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
||||
@@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService {
|
||||
}
|
||||
|
||||
// 단일 객체인 경우
|
||||
const results = conditions.map((condition: any) => {
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
const fieldValue = inputData[condition.field];
|
||||
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
// EXISTS 계열 연산자 처리
|
||||
if (
|
||||
condition.operator === "EXISTS_IN" ||
|
||||
condition.operator === "NOT_EXISTS_IN"
|
||||
) {
|
||||
const existsResult = await this.evaluateExistsCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
condition.lookupTable,
|
||||
condition.lookupField,
|
||||
context.buttonContext?.companyCode
|
||||
);
|
||||
results.push(existsResult);
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
// 일반 연산자 처리
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
|
||||
results.push(
|
||||
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||
);
|
||||
}
|
||||
|
||||
return this.evaluateCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
compareValue
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const result =
|
||||
logic === "OR"
|
||||
@@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService {
|
||||
|
||||
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
||||
|
||||
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
||||
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
||||
return {
|
||||
@@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
*/
|
||||
private static async evaluateExistsCondition(
|
||||
fieldValue: any,
|
||||
operator: string,
|
||||
lookupTable: string,
|
||||
lookupField: string,
|
||||
companyCode?: string
|
||||
): Promise<boolean> {
|
||||
if (!lookupTable || !lookupField) {
|
||||
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||
logger.info(
|
||||
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
||||
);
|
||||
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
||||
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 멀티테넌시: company_code 필터 적용 여부 확인
|
||||
// company_mng 테이블은 제외
|
||||
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode) {
|
||||
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
|
||||
params = [fieldValue, companyCode];
|
||||
} else {
|
||||
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
|
||||
params = [fieldValue];
|
||||
}
|
||||
|
||||
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
|
||||
|
||||
const result = await query(sql, params);
|
||||
const existsInTable = result[0]?.exists_result === true;
|
||||
|
||||
logger.info(
|
||||
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
|
||||
);
|
||||
|
||||
// EXISTS_IN: 존재하면 true
|
||||
// NOT_EXISTS_IN: 존재하지 않으면 true
|
||||
if (operator === "EXISTS_IN") {
|
||||
return existsInTable;
|
||||
} else {
|
||||
return !existsInTable;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 생성
|
||||
*/
|
||||
@@ -4280,6 +4446,8 @@ export class NodeFlowExecutionService {
|
||||
|
||||
/**
|
||||
* 산술 연산 계산
|
||||
* 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용
|
||||
* 예: (width * height) / 1000000 * qty
|
||||
*/
|
||||
private static evaluateArithmetic(
|
||||
arithmetic: any,
|
||||
@@ -4306,27 +4474,67 @@ export class NodeFlowExecutionService {
|
||||
const leftNum = Number(left) || 0;
|
||||
const rightNum = Number(right) || 0;
|
||||
|
||||
switch (arithmetic.operator) {
|
||||
// 기본 연산 수행
|
||||
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추가 연산 처리 (다중 연산 지원)
|
||||
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const operandValue = this.getOperandValue(
|
||||
addOp.operand,
|
||||
sourceRow,
|
||||
targetRow,
|
||||
resultValues
|
||||
);
|
||||
const operandNum = Number(operandValue) || 0;
|
||||
|
||||
result = this.applyOperator(result, addOp.operator, operandNum);
|
||||
|
||||
if (result === null) {
|
||||
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 연산자 적용
|
||||
*/
|
||||
private static applyOperator(
|
||||
left: number,
|
||||
operator: string,
|
||||
right: number
|
||||
): number | null {
|
||||
switch (operator) {
|
||||
case "+":
|
||||
return leftNum + rightNum;
|
||||
return left + right;
|
||||
case "-":
|
||||
return leftNum - rightNum;
|
||||
return left - right;
|
||||
case "*":
|
||||
return leftNum * rightNum;
|
||||
return left * right;
|
||||
case "/":
|
||||
if (rightNum === 0) {
|
||||
if (right === 0) {
|
||||
logger.warn(`⚠️ 0으로 나누기 시도`);
|
||||
return null;
|
||||
}
|
||||
return leftNum / rightNum;
|
||||
return left / right;
|
||||
case "%":
|
||||
if (rightNum === 0) {
|
||||
if (right === 0) {
|
||||
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
||||
return null;
|
||||
}
|
||||
return leftNum % rightNum;
|
||||
return left % right;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
|
||||
throw new Error(`지원하지 않는 연산자: ${operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,71 +187,68 @@ class TableCategoryValueService {
|
||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
}
|
||||
|
||||
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||
query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 카테고리 값 조회");
|
||||
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
||||
params = [tableName, columnName, siblingObjids];
|
||||
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND menu_objid = $3`;
|
||||
params = [tableName, columnName, menuObjid];
|
||||
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
||||
}
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 조회
|
||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||
query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회", { companyCode });
|
||||
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
||||
params = [tableName, columnName, companyCode, siblingObjids];
|
||||
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
||||
params = [tableName, columnName, companyCode, menuObjid];
|
||||
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
||||
query = baseSelect + ` AND company_code = $3`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
@@ -1398,6 +1395,220 @@ class TableCategoryValueService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
|
||||
*
|
||||
* 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelToCodeMapping(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
try {
|
||||
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
|
||||
const categoryColumnsQuery = `
|
||||
SELECT column_name
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'category'
|
||||
`;
|
||||
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
|
||||
|
||||
if (categoryColumnsResult.rows.length === 0) {
|
||||
logger.info("카테고리 타입 컬럼 없음", { tableName });
|
||||
return {};
|
||||
}
|
||||
|
||||
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
|
||||
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
|
||||
|
||||
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const columnName of categoryColumns) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const valuesResult = await pool.query(query, params);
|
||||
|
||||
// { [label]: code } 형태로 변환
|
||||
const labelToCodeMap: Record<string, string> = {};
|
||||
for (const row of valuesResult.rows) {
|
||||
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
|
||||
labelToCodeMap[row.value_label] = row.value_code;
|
||||
// 소문자 키도 추가 (대소문자 무시 검색용)
|
||||
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
|
||||
}
|
||||
|
||||
if (Object.keys(labelToCodeMap).length > 0) {
|
||||
result[columnName] = labelToCodeMap;
|
||||
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
|
||||
tableName,
|
||||
columnCount: Object.keys(result).length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터의 카테고리 라벨 값을 코드 값으로 변환
|
||||
*
|
||||
* 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @param data - 변환할 데이터 객체
|
||||
* @returns 라벨이 코드로 변환된 데이터 객체
|
||||
*/
|
||||
async convertCategoryLabelsToCodesForData(
|
||||
tableName: string,
|
||||
companyCode: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
|
||||
try {
|
||||
// 라벨→코드 매핑 조회
|
||||
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
|
||||
|
||||
if (Object.keys(labelToCodeMapping).length === 0) {
|
||||
// 카테고리 컬럼 없음
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
|
||||
const convertedData = { ...data };
|
||||
const conversions: Array<{ column: string; label: string; code: string }> = [];
|
||||
|
||||
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
|
||||
const value = data[columnName];
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const stringValue = String(value).trim();
|
||||
|
||||
// 다중 값 확인 (쉼표로 구분된 경우)
|
||||
if (stringValue.includes(",")) {
|
||||
// 다중 카테고리 값 처리
|
||||
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
|
||||
const convertedCodes: string[] = [];
|
||||
let allConverted = true;
|
||||
|
||||
for (const label of labels) {
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[label];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[label.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
convertedCodes.push(matchedCode);
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: label,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
|
||||
if (isAlreadyCode) {
|
||||
// 이미 코드값이면 그대로 사용
|
||||
convertedCodes.push(label);
|
||||
} else {
|
||||
// 라벨도 코드도 아니면 원래 값 유지
|
||||
convertedCodes.push(label);
|
||||
allConverted = false;
|
||||
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 변환된 코드들을 쉼표로 합쳐서 저장
|
||||
convertedData[columnName] = convertedCodes.join(",");
|
||||
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
|
||||
} else {
|
||||
// 단일 값 처리
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[stringValue];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[stringValue.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
// 라벨 값을 코드 값으로 변환
|
||||
convertedData[columnName] = matchedCode;
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: stringValue,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인 (역방향 확인)
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
|
||||
if (!isAlreadyCode) {
|
||||
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
// 변환 없이 원래 값 유지
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 변환 완료`, {
|
||||
tableName,
|
||||
conversionCount: conversions.length,
|
||||
conversions,
|
||||
});
|
||||
|
||||
return { convertedData, conversions };
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
|
||||
// 실패 시 원본 데이터 반환
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
||||
@@ -1312,6 +1312,41 @@ export class TableManagementService {
|
||||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
|
||||
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// 배열의 각 값에 대해 OR 조건으로 검색
|
||||
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
|
||||
// 각 값을 LIKE 또는 = 조건으로 처리
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
value.forEach((v: any, idx: number) => {
|
||||
const safeValue = String(v).trim();
|
||||
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
||||
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
|
||||
// - 정확히 "2"
|
||||
// - "2," 로 시작
|
||||
// - ",2" 로 끝남
|
||||
// - ",2," 중간에 포함
|
||||
const paramBase = paramIndex + (idx * 4);
|
||||
conditions.push(`(
|
||||
${columnName}::text = $${paramBase} OR
|
||||
${columnName}::text LIKE $${paramBase + 1} OR
|
||||
${columnName}::text LIKE $${paramBase + 2} OR
|
||||
${columnName}::text LIKE $${paramBase + 3}
|
||||
)`);
|
||||
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
|
||||
});
|
||||
|
||||
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
|
||||
return {
|
||||
whereClause: `(${conditions.join(" OR ")})`,
|
||||
values,
|
||||
paramCount: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(
|
||||
@@ -2267,11 +2302,12 @@ export class TableManagementService {
|
||||
|
||||
/**
|
||||
* 테이블에 데이터 추가
|
||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||
*/
|
||||
async addTableData(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
||||
try {
|
||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||
logger.info(`추가할 데이터:`, data);
|
||||
@@ -2302,10 +2338,41 @@ export class TableManagementService {
|
||||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data).map((value, index) => {
|
||||
const columnName = columns[index];
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
const exists = columnTypeMap.has(col);
|
||||
if (!exists) {
|
||||
skippedColumns.push(col);
|
||||
}
|
||||
return exists;
|
||||
});
|
||||
|
||||
// 무시된 컬럼이 있으면 경고 로그 출력
|
||||
if (skippedColumns.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
|
||||
);
|
||||
logger.warn(
|
||||
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
|
||||
skippedColumns.map((col) => ({ column: col, value: data[col] }))
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumns.length === 0) {
|
||||
throw new Error(
|
||||
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
|
||||
);
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
|
||||
const columns = existingColumns;
|
||||
const values = columns.map((columnName) => {
|
||||
const value = data[columnName];
|
||||
const dataType = columnTypeMap.get(columnName) || "text";
|
||||
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
|
||||
logger.info(
|
||||
@@ -2361,6 +2428,12 @@ export class TableManagementService {
|
||||
await query(insertQuery, values);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
|
||||
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
||||
return {
|
||||
skippedColumns,
|
||||
savedColumns: existingColumns,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||
throw error;
|
||||
@@ -2644,6 +2717,12 @@ export class TableManagementService {
|
||||
filterColumn?: string;
|
||||
filterValue?: any;
|
||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
deduplication?: {
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
}; // 🆕 중복 제거 설정
|
||||
}
|
||||
): Promise<EntityJoinResponse> {
|
||||
const startTime = Date.now();
|
||||
@@ -2694,33 +2773,64 @@ export class TableManagementService {
|
||||
);
|
||||
|
||||
for (const additionalColumn of options.additionalJoinColumns) {
|
||||
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
||||
const baseJoinConfig = joinConfigs.find(
|
||||
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
||||
let baseJoinConfig = joinConfigs.find(
|
||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||
);
|
||||
|
||||
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
||||
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
||||
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
||||
baseJoinConfig = joinConfigs.find(
|
||||
(config) => config.referenceTable === (additionalColumn as any).referenceTable
|
||||
);
|
||||
if (baseJoinConfig) {
|
||||
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (baseJoinConfig) {
|
||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
||||
// joinAlias에서 실제 컬럼명 추출
|
||||
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||
|
||||
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||
let actualColumnName: string;
|
||||
|
||||
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
|
||||
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
|
||||
} else {
|
||||
// 어느 것도 아니면 원본 사용
|
||||
actualColumnName = originalJoinAlias;
|
||||
}
|
||||
|
||||
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||
|
||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||
sourceColumn,
|
||||
joinAlias,
|
||||
frontendSourceColumn,
|
||||
originalJoinAlias,
|
||||
correctedJoinAlias,
|
||||
actualColumnName,
|
||||
referenceTable: additionalColumn.sourceTable,
|
||||
referenceTable: (additionalColumn as any).referenceTable,
|
||||
});
|
||||
|
||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||
const isBasicEntityJoin =
|
||||
additionalColumn.joinAlias ===
|
||||
`${baseJoinConfig.sourceColumn}_name`;
|
||||
correctedJoinAlias === `${sourceColumn}_name`;
|
||||
|
||||
if (isBasicEntityJoin) {
|
||||
logger.info(
|
||||
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
||||
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
||||
);
|
||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||
}
|
||||
@@ -2728,14 +2838,14 @@ export class TableManagementService {
|
||||
// 추가 조인 컬럼 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
@@ -3702,6 +3812,15 @@ export class TableManagementService {
|
||||
const cacheableJoins: EntityJoinConfig[] = [];
|
||||
const dbJoins: EntityJoinConfig[] = [];
|
||||
|
||||
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||
const companySpecificTables = [
|
||||
"supplier_mng",
|
||||
"customer_mng",
|
||||
"item_info",
|
||||
"dept_info",
|
||||
// 필요시 추가
|
||||
];
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
@@ -3710,6 +3829,13 @@ export class TableManagementService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||
if (companySpecificTables.includes(config.referenceTable)) {
|
||||
dbJoins.push(config);
|
||||
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 캐시 가능성 확인
|
||||
const cachedData = await referenceCacheService.getCachedReference(
|
||||
config.referenceTable,
|
||||
@@ -4598,4 +4724,101 @@ export class TableManagementService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
||||
*
|
||||
* @param leftTable 좌측 테이블명
|
||||
* @param rightTable 우측 테이블명
|
||||
* @returns 감지된 엔티티 관계 배열
|
||||
*/
|
||||
async detectTableEntityRelations(
|
||||
leftTable: string,
|
||||
rightTable: string
|
||||
): Promise<Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
direction: "left_to_right" | "right_to_left";
|
||||
inputType: string;
|
||||
displayColumn?: string;
|
||||
}>> {
|
||||
try {
|
||||
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
|
||||
|
||||
const relations: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
direction: "left_to_right" | "right_to_left";
|
||||
inputType: string;
|
||||
displayColumn?: string;
|
||||
}> = [];
|
||||
|
||||
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
|
||||
const rightToLeftRels = await query<{
|
||||
column_name: string;
|
||||
reference_column: string;
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''`,
|
||||
[rightTable, leftTable]
|
||||
);
|
||||
|
||||
for (const rel of rightToLeftRels) {
|
||||
relations.push({
|
||||
leftColumn: rel.reference_column,
|
||||
rightColumn: rel.column_name,
|
||||
direction: "right_to_left",
|
||||
inputType: rel.input_type,
|
||||
displayColumn: rel.display_column || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||
// 예: left_table의 item_id -> right_table(item_info)의 item_number
|
||||
const leftToRightRels = await query<{
|
||||
column_name: string;
|
||||
reference_column: string;
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''`,
|
||||
[leftTable, rightTable]
|
||||
);
|
||||
|
||||
for (const rel of leftToRightRels) {
|
||||
relations.push({
|
||||
leftColumn: rel.column_name,
|
||||
rightColumn: rel.reference_column,
|
||||
direction: "left_to_right",
|
||||
inputType: rel.input_type,
|
||||
displayColumn: rel.display_column || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
||||
relations.forEach((rel, idx) => {
|
||||
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
|
||||
});
|
||||
|
||||
return relations;
|
||||
} catch (error) {
|
||||
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user