import { query, queryOne, transaction } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; export interface FormDataResult { id: number; screenId: number; tableName: string; data: Record; createdAt: Date | null; updatedAt: Date | null; createdBy: string; updatedBy: string; } export interface PartialUpdateResult { success: boolean; data: any; message: string; } export interface PaginatedFormData { content: FormDataResult[]; totalElements: number; totalPages: number; currentPage: number; size: number; } export interface ValidationError { field: string; message: string; code: string; } export interface ValidationResult { valid: boolean; errors: ValidationError[]; } export interface TableColumn { columnName: string; dataType: string; nullable: boolean; primaryKey: boolean; maxLength?: number | null; defaultValue?: any; } export class DynamicFormService { private dataflowControlService = new DataflowControlService(); /** * 값을 PostgreSQL 타입에 맞게 변환 */ private convertValueForPostgreSQL(value: any, dataType: string): any { if (value === null || value === undefined || value === "") { return null; } const lowerDataType = dataType.toLowerCase(); // 숫자 타입 처리 if ( lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial") ) { return parseInt(value) || null; } if ( lowerDataType.includes("numeric") || lowerDataType.includes("decimal") || lowerDataType.includes("real") || lowerDataType.includes("double") ) { return parseFloat(value) || null; } // 불린 타입 처리 if (lowerDataType.includes("boolean")) { if (typeof value === "boolean") return value; if (typeof value === "string") { return value.toLowerCase() === "true" || value === "1"; } return Boolean(value); } // 날짜/시간 타입 처리 if ( lowerDataType.includes("date") || lowerDataType.includes("timestamp") || lowerDataType.includes("time") ) { if (typeof value === "string") { // 빈 문자열이면 null 반환 if (value.trim() === "") { return null; } try { // YYYY-MM-DD 형식인 경우 if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { // DATE 타입이면 문자열 그대로 유지 if (lowerDataType === "date") { console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`); return value; // 문자열 그대로 반환 } // TIMESTAMP 타입이면 Date 객체로 변환 else { console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`); return new Date(value + "T00:00:00"); } } // 다른 날짜 형식도 Date 객체로 변환 else { console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); return new Date(value); } } catch (error) { console.error(`❌ 날짜 변환 실패: ${value}`, error); return null; } } // 이미 Date 객체인 경우 그대로 반환 if (value instanceof Date) { return value; } // 숫자인 경우 timestamp로 처리 if (typeof value === "number") { return new Date(value); } return null; } // 기본적으로 문자열로 반환 return value; } /** * 테이블의 컬럼 정보 조회 (타입 포함) */ private async getTableColumnInfo( tableName: string ): Promise> { try { const result = await query<{ column_name: string; data_type: string }>( `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`, [tableName] ); return result; } catch (error) { console.error(`테이블 ${tableName}의 컬럼 정보 조회 실패:`, error); return []; } } /** * 테이블의 컬럼명 목록 조회 (간단 버전) */ private async getTableColumnNames(tableName: string): Promise { try { const result = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`, [tableName] ); return result.map((row) => row.column_name); } catch (error) { console.error(`❌ 테이블 ${tableName} 컬럼 정보 조회 실패:`, error); return []; } } /** * 테이블의 Primary Key 컬럼 조회 (공개 메서드로 변경) */ async getTablePrimaryKeys(tableName: string): Promise { try { const result = await query<{ column_name: string }>( `SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'`, [tableName] ); return result.map((row) => row.column_name); } catch (error) { console.error(`❌ 테이블 ${tableName} Primary Key 조회 실패:`, error); return []; } } /** * 폼 데이터 저장 (실제 테이블에 직접 저장) */ async saveFormData( screenId: number, tableName: string, data: Record, ipAddress?: string ): Promise { try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { screenId, tableName, data, }); // 테이블의 실제 컬럼 정보와 Primary Key 조회 const tableColumns = await this.getTableColumnNames(tableName); const primaryKeys = await this.getTablePrimaryKeys(tableName); console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys); // 메타데이터 제거 (실제 테이블 컬럼이 아님) const { created_by, updated_by, writer, company_code, screen_id, ...actualData } = data; // 기본 데이터 준비 const dataToInsert: any = { ...actualData }; // 테이블에 존재하는 공통 필드들만 추가 if (tableColumns.includes("created_at")) { dataToInsert.created_at = new Date(); } if (tableColumns.includes("updated_at")) { dataToInsert.updated_at = new Date(); } if (tableColumns.includes("regdate") && !dataToInsert.regdate) { dataToInsert.regdate = new Date(); } if (tableColumns.includes("created_date") && !dataToInsert.created_date) { dataToInsert.created_date = new Date(); } if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) { dataToInsert.updated_date = new Date(); } // 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by) if (writer && tableColumns.includes("writer")) { dataToInsert.writer = writer; } if (created_by && tableColumns.includes("created_by")) { dataToInsert.created_by = created_by; } if (updated_by && tableColumns.includes("updated_by")) { dataToInsert.updated_by = updated_by; } if (company_code && tableColumns.includes("company_code")) { // company_code가 UUID 형태(36자)라면 하이픈 제거하여 32자로 만듦 let processedCompanyCode = company_code; if ( typeof company_code === "string" && company_code.length === 36 && company_code.includes("-") ) { processedCompanyCode = company_code.replace(/-/g, ""); console.log( `🔧 company_code 길이 조정: "${company_code}" -> "${processedCompanyCode}" (${processedCompanyCode.length}자)` ); } // 여전히 32자를 초과하면 앞의 32자만 사용 if ( typeof processedCompanyCode === "string" && processedCompanyCode.length > 32 ) { processedCompanyCode = processedCompanyCode.substring(0, 32); console.log( `⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"` ); } dataToInsert.company_code = processedCompanyCode; } // 날짜/시간 문자열을 적절한 형태로 변환 Object.keys(dataToInsert).forEach((key) => { const value = dataToInsert[key]; // 날짜/시간 관련 컬럼명 패턴 체크 (regdate, created_at, updated_at 등) if ( typeof value === "string" && (key.toLowerCase().includes("date") || key.toLowerCase().includes("time") || key.toLowerCase().includes("created") || key.toLowerCase().includes("updated") || key.toLowerCase().includes("reg")) ) { // YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환 if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) { console.log(`📅 날짜시간 변환: ${key} = "${value}" -> Date 객체`); dataToInsert[key] = new Date(value); } // YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장) else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) { console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`); // dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식) } } }); // 📝 RepeaterInput 데이터 처리 (JSON 배열을 개별 레코드로 분해) const repeaterData: Array<{ data: Record[]; targetTable?: string; componentId: string; }> = []; Object.keys(dataToInsert).forEach((key) => { const value = dataToInsert[key]; // 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열) let parsedArray: any[] | null = null; // 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등) if (Array.isArray(value) && value.length > 0) { parsedArray = value; console.log( `🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목` ); } // 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput) else if ( typeof value === "string" && value.trim().startsWith("[") && value.trim().endsWith("]") ) { try { parsedArray = JSON.parse(value); console.log( `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` ); } catch (parseError) { console.log(`⚠️ JSON 파싱 실패: ${key}`); } } // 파싱된 배열이 있으면 처리 if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 let targetTable: string | undefined; let actualData = parsedArray; // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) if (parsedArray[0] && parsedArray[0]._targetTable) { targetTable = parsedArray[0]._targetTable; actualData = parsedArray.map( ({ _targetTable, ...item }) => item ); } repeaterData.push({ data: actualData, targetTable, componentId: key, }); delete dataToInsert[key]; // 원본 배열 데이터는 제거 console.log(`✅ Repeater 데이터 추가: ${key}`, { targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", itemCount: actualData.length, firstItem: actualData[0], }); } }); // 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장 const separateRepeaterData: typeof repeaterData = []; const mergedRepeaterData: typeof repeaterData = []; repeaterData.forEach(repeater => { if (repeater.targetTable && repeater.targetTable !== tableName) { // 다른 테이블: 나중에 별도 저장 separateRepeaterData.push(repeater); } else { // 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에) mergedRepeaterData.push(repeater); } }); console.log(`🔄 Repeater 데이터 분류:`, { separate: separateRepeaterData.length, // 별도 테이블 merged: mergedRepeaterData.length, // 메인 테이블과 병합 }); // 존재하지 않는 컬럼 제거 Object.keys(dataToInsert).forEach((key) => { if (!tableColumns.includes(key)) { console.log( `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` ); delete dataToInsert[key]; } }); console.log("🎯 실제 테이블에 삽입할 데이터:", { tableName, dataToInsert, }); // 테이블 컬럼 정보 조회하여 타입 변환 적용 console.log("🔍 테이블 컬럼 정보 조회 중..."); const columnInfo = await this.getTableColumnInfo(tableName); console.log("📊 테이블 컬럼 정보:", columnInfo); // 각 컬럼의 타입에 맞게 데이터 변환 Object.keys(dataToInsert).forEach((columnName) => { const column = columnInfo.find((col) => col.column_name === columnName); if (column) { const originalValue = dataToInsert[columnName]; const convertedValue = this.convertValueForPostgreSQL( originalValue, column.data_type ); if (originalValue !== convertedValue) { console.log( `🔄 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}` ); dataToInsert[columnName] = convertedValue; } } }); console.log("✅ 타입 변환 완료된 데이터:", dataToInsert); // 동적 SQL을 사용하여 실제 테이블에 UPSERT const columns = Object.keys(dataToInsert); const values: any[] = Object.values(dataToInsert); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); let upsertQuery: string; if (primaryKeys.length > 0) { // Primary Key가 있는 경우 UPSERT 사용 const conflictColumns = primaryKeys.join(", "); const updateSet = columns .filter((col) => !primaryKeys.includes(col)) // Primary Key는 UPDATE에서 제외 .map((col) => `${col} = EXCLUDED.${col}`) .join(", "); if (updateSet) { upsertQuery = ` INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns}) DO UPDATE SET ${updateSet} RETURNING * `; } else { // 업데이트할 컬럼이 없는 경우 (Primary Key만 있는 테이블) upsertQuery = ` INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns}) DO NOTHING RETURNING * `; } } else { // Primary Key가 없는 경우 일반 INSERT upsertQuery = ` INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders}) RETURNING * `; } console.log("📝 실행할 UPSERT SQL:", upsertQuery); console.log("📊 SQL 파라미터:", values); // 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서) const userId = data.updated_by || data.created_by || "system"; const clientIp = ipAddress || "unknown"; let result: any[]; // 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT if (mergedRepeaterData.length > 0) { console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`); result = []; for (const repeater of mergedRepeaterData) { for (const item of repeater.data) { // 헤더 + 품목을 병합 const rawMergedData = { ...dataToInsert, ...item }; // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) const validColumnNames = columnInfo.map((col) => col.column_name); const mergedData: Record = {}; Object.keys(rawMergedData).forEach((columnName) => { // 실제 테이블 컬럼인지 확인 if (validColumnNames.includes(columnName)) { const column = columnInfo.find((col) => col.column_name === columnName); if (column) { // 타입 변환 mergedData[columnName] = this.convertValueForPostgreSQL( rawMergedData[columnName], column.data_type ); } else { mergedData[columnName] = rawMergedData[columnName]; } } else { console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); } }); const mergedColumns = Object.keys(mergedData); const mergedValues: any[] = Object.values(mergedData); const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", "); let mergedUpsertQuery: string; if (primaryKeys.length > 0) { const conflictColumns = primaryKeys.join(", "); const updateSet = mergedColumns .filter((col) => !primaryKeys.includes(col)) .map((col) => `${col} = EXCLUDED.${col}`) .join(", "); mergedUpsertQuery = updateSet ? `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) VALUES (${mergedPlaceholders}) ON CONFLICT (${conflictColumns}) DO UPDATE SET ${updateSet} RETURNING *` : `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) VALUES (${mergedPlaceholders}) ON CONFLICT (${conflictColumns}) DO NOTHING RETURNING *`; } else { mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) VALUES (${mergedPlaceholders}) RETURNING *`; } console.log(`📝 병합 INSERT:`, { mergedData }); const itemResult = await transaction(async (client) => { await client.query(`SET LOCAL app.user_id = '${userId}'`); await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); const res = await client.query(mergedUpsertQuery, mergedValues); return res.rows[0]; }); result.push(itemResult); } } console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`); } else { // 일반 모드: 헤더만 저장 result = await transaction(async (client) => { await client.query(`SET LOCAL app.user_id = '${userId}'`); await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); const res = await client.query(upsertQuery, values); return res.rows; }); console.log("✅ 서비스: 실제 테이블 저장 성공:", result); } // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; // 📝 별도 테이블 Repeater 데이터 저장 if (separateRepeaterData.length > 0) { console.log( `🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개` ); for (const repeater of separateRepeaterData) { const targetTableName = repeater.targetTable || tableName; console.log( `📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장` ); // 대상 테이블의 컬럼 및 기본키 정보 조회 const targetTableColumns = await this.getTableColumns(targetTableName); const targetPrimaryKeys = await this.getPrimaryKeys(targetTableName); // 컬럼명만 추출 const targetColumnNames = targetTableColumns.map( (col) => col.columnName ); // 각 항목을 저장 for (let i = 0; i < repeater.data.length; i++) { const item = repeater.data[i]; const itemData: Record = { ...item, created_by, updated_by, regdate: new Date(), // 🔥 멀티테넌시: company_code 필수 추가 company_code: data.company_code || company_code, }; // 🔥 별도 테이블인 경우에만 외래키 추가 // (같은 테이블이면 이미 병합 모드에서 처리됨) // 대상 테이블에 존재하는 컬럼만 필터링 Object.keys(itemData).forEach((key) => { if (!targetColumnNames.includes(key)) { delete itemData[key]; } }); // 타입 변환 적용 Object.keys(itemData).forEach((columnName) => { const column = targetTableColumns.find( (col) => col.columnName === columnName ); if (column) { itemData[columnName] = this.convertValueForPostgreSQL( itemData[columnName], column.dataType ); } }); // UPSERT 쿼리 생성 const itemColumns = Object.keys(itemData); const itemValues: any[] = Object.values(itemData); const itemPlaceholders = itemValues .map((_, index) => `$${index + 1}`) .join(", "); let itemUpsertQuery: string; if (targetPrimaryKeys.length > 0) { const conflictColumns = targetPrimaryKeys.join(", "); const updateSet = itemColumns .filter((col) => !targetPrimaryKeys.includes(col)) .map((col) => `${col} = EXCLUDED.${col}`) .join(", "); if (updateSet) { itemUpsertQuery = ` INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) VALUES (${itemPlaceholders}) ON CONFLICT (${conflictColumns}) DO UPDATE SET ${updateSet} RETURNING * `; } else { itemUpsertQuery = ` INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) VALUES (${itemPlaceholders}) ON CONFLICT (${conflictColumns}) DO NOTHING RETURNING * `; } } else { itemUpsertQuery = ` INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) VALUES (${itemPlaceholders}) RETURNING * `; } console.log( ` 📝 항목 ${i + 1}/${repeater.data.length} 저장:`, itemData ); await query(itemUpsertQuery, itemValues); } console.log(` ✅ Repeater "${repeater.componentId}" 저장 완료`); } console.log(`✅ 모든 RepeaterInput 데이터 저장 완료`); } // 🔥 조건부 연결 실행 (INSERT 트리거) try { if (company_code) { await EventTriggerService.executeEventTriggers( "insert", tableName, insertedRecord as Record, company_code ); console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)"); } } catch (triggerError) { console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); // 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행 } // 🎯 제어관리 실행 (새로 추가) try { await this.executeDataflowControlIfConfigured( screenId, tableName, insertedRecord as Record, "insert", created_by || "system" ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); // 제어관리 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행 } return { id: insertedRecord.id || insertedRecord.objid || 0, screenId: screenId, tableName: tableName, data: insertedRecord as Record, createdAt: insertedRecord.created_at || new Date(), updatedAt: insertedRecord.updated_at || new Date(), createdBy: insertedRecord.created_by || created_by || "system", updatedBy: insertedRecord.updated_by || updated_by || "system", }; } catch (error) { console.error("❌ 서비스: 실제 테이블 저장 실패:", error); throw new Error(`실제 테이블 저장 실패: ${error}`); } } /** * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) */ async updateFormDataPartial( id: number, tableName: string, originalData: Record, newData: Record ): Promise { try { console.log("🔄 서비스: 부분 업데이트 시작:", { id, tableName, originalData, newData, }); // 테이블의 실제 컬럼 정보 조회 const tableColumns = await this.getTableColumnNames(tableName); console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); // 변경된 필드만 찾기 const changedFields: Record = {}; for (const [key, value] of Object.entries(newData)) { // 메타데이터 필드 제외 if ( ["created_by", "updated_by", "company_code", "screen_id"].includes( key ) ) { continue; } // 테이블에 존재하지 않는 컬럼 제외 if (!tableColumns.includes(key)) { console.log( `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제외됨` ); continue; } // 값이 실제로 변경된 경우만 포함 if (originalData[key] !== value) { changedFields[key] = value; console.log( `📝 변경된 필드: ${key} = "${originalData[key]}" → "${value}"` ); } } // 변경된 필드가 없으면 업데이트 건너뛰기 if (Object.keys(changedFields).length === 0) { console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다."); return { success: true, data: originalData, message: "변경사항이 없어 업데이트하지 않았습니다.", }; } // 업데이트 관련 필드 추가 (변경사항이 있는 경우에만) if (tableColumns.includes("updated_at")) { changedFields.updated_at = new Date(); } console.log("🎯 실제 업데이트할 필드들:", changedFields); // 동적으로 기본키 조회 const primaryKeys = await this.getTablePrimaryKeys(tableName); if (!primaryKeys || primaryKeys.length === 0) { throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); } const primaryKeyColumn = primaryKeys[0]; console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); // 🆕 컬럼 타입 조회 (타입 캐스팅용) const columnTypesQuery = ` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' `; const columnTypesResult = await query<{ column_name: string; data_type: string }>( columnTypesQuery, [tableName] ); const columnTypes: Record = {}; columnTypesResult.forEach((row) => { columnTypes[row.column_name] = row.data_type; }); console.log("📊 컬럼 타입 정보:", columnTypes); // 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함) const setClause = Object.keys(changedFields) .map((key, index) => { const dataType = columnTypes[key]; // 숫자 타입인 경우 명시적 캐스팅 if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { return `${key} = $${index + 1}::integer`; } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { return `${key} = $${index + 1}::numeric`; } else if (dataType === 'boolean') { return `${key} = $${index + 1}::boolean`; } else { // 문자열 타입은 캐스팅 불필요 return `${key} = $${index + 1}`; } }) .join(", "); const values: any[] = Object.values(changedFields); values.push(id); // WHERE 조건용 ID 추가 // 🔑 Primary Key 타입에 맞게 캐스팅 const pkDataType = columnTypes[primaryKeyColumn]; let pkCast = ''; if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') { pkCast = '::integer'; } else if (pkDataType === 'numeric' || pkDataType === 'decimal') { pkCast = '::numeric'; } else if (pkDataType === 'uuid') { pkCast = '::uuid'; } // text, varchar 등은 캐스팅 불필요 const updateQuery = ` UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = $${values.length}${pkCast} RETURNING * `; console.log("📝 실행할 부분 UPDATE SQL:", updateQuery); console.log("📊 SQL 파라미터:", values); const result = await query(updateQuery, values); console.log("✅ 서비스: 부분 업데이트 성공:", result); const updatedRecord = Array.isArray(result) ? result[0] : result; return { success: true, data: updatedRecord, message: "데이터가 성공적으로 업데이트되었습니다.", }; } catch (error: any) { console.error("❌ 서비스: 부분 업데이트 실패:", error); throw new Error(`부분 업데이트 실패: ${error}`); } } /** * 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트) */ async updateFormData( id: string | number, tableName: string, data: Record ): Promise { try { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { id, tableName, data, }); // 테이블의 실제 컬럼 정보 조회 const tableColumns = await this.getTableColumnNames(tableName); console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); // 메타데이터 제거 const { created_by, updated_by, company_code, screen_id, ...actualData } = data; // 기본 데이터 준비 const dataToUpdate: any = { ...actualData }; // 테이블에 존재하는 업데이트 관련 필드들만 추가 if (tableColumns.includes("updated_at")) { dataToUpdate.updated_at = new Date(); } if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { dataToUpdate.regdate = new Date(); } // 수정자 정보가 있고 해당 컬럼이 존재한다면 추가 if (updated_by && tableColumns.includes("updated_by")) { dataToUpdate.updated_by = updated_by; } // 존재하지 않는 컬럼 제거 Object.keys(dataToUpdate).forEach((key) => { if (!tableColumns.includes(key)) { console.log( `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` ); delete dataToUpdate[key]; } }); // 컬럼 타입에 맞는 데이터 변환 (UPDATE용) const columnInfo = await this.getTableColumnInfo(tableName); console.log(`📊 테이블 ${tableName}의 컬럼 타입 정보:`, columnInfo); // 각 컬럼의 타입에 맞게 데이터 변환 Object.keys(dataToUpdate).forEach((columnName) => { const column = columnInfo.find((col) => col.column_name === columnName); if (column) { const originalValue = dataToUpdate[columnName]; const convertedValue = this.convertValueForPostgreSQL( originalValue, column.data_type ); if (originalValue !== convertedValue) { console.log( `🔄 UPDATE 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}` ); dataToUpdate[columnName] = convertedValue; } } }); console.log("✅ UPDATE 타입 변환 완료된 데이터:", dataToUpdate); console.log("🎯 실제 테이블에서 업데이트할 데이터:", { tableName, id, dataToUpdate, }); // 동적 UPDATE SQL 생성 const setClause = Object.keys(dataToUpdate) .map((key, index) => `${key} = $${index + 1}`) .join(", "); const values: any[] = Object.values(dataToUpdate); values.push(id); // WHERE 조건용 ID 추가 // 동적으로 기본키 조회 const primaryKeys = await this.getTablePrimaryKeys(tableName); if (!primaryKeys || primaryKeys.length === 0) { throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); } const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용 console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); // 기본키 데이터 타입 조회하여 적절한 캐스팅 적용 const primaryKeyInfo = await query<{ data_type: string }>( `SELECT data_type FROM information_schema.columns WHERE table_name = $1 AND column_name = $2 AND table_schema = 'public'`, [tableName, primaryKeyColumn] ); let typeCastSuffix = ""; if (primaryKeyInfo.length > 0) { const dataType = primaryKeyInfo[0].data_type; console.log(`🔍 기본키 ${primaryKeyColumn}의 데이터 타입: ${dataType}`); if (dataType.includes("character") || dataType.includes("text")) { typeCastSuffix = "::text"; } else if (dataType.includes("bigint")) { typeCastSuffix = "::bigint"; } else if ( dataType.includes("integer") || dataType.includes("numeric") ) { typeCastSuffix = "::numeric"; } } const updateQuery = ` UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = $${values.length}${typeCastSuffix} RETURNING * `; console.log("📝 실행할 UPDATE SQL:", updateQuery); console.log("📊 SQL 파라미터:", values); const result = await query(updateQuery, values); console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result); const updatedRecord = Array.isArray(result) ? result[0] : result; // 🔥 조건부 연결 실행 (UPDATE 트리거) try { if (company_code) { await EventTriggerService.executeEventTriggers( "update", tableName, updatedRecord as Record, company_code ); console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)"); } } catch (triggerError) { console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); // 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행 } // 🎯 제어관리 실행 (UPDATE 트리거) try { await this.executeDataflowControlIfConfigured( 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, "update", updated_by || "system" ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); // 제어관리 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행 } return { id: updatedRecord.id || updatedRecord.objid || id, screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정 tableName: tableName, data: updatedRecord as Record, createdAt: updatedRecord.created_at || new Date(), updatedAt: updatedRecord.updated_at || new Date(), createdBy: updatedRecord.created_by || "system", updatedBy: updatedRecord.updated_by || updated_by || "system", }; } catch (error) { console.error("❌ 서비스: 실제 테이블 업데이트 실패:", error); throw new Error(`실제 테이블 업데이트 실패: ${error}`); } } /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) */ async deleteFormData( id: string | number, tableName: string, companyCode?: string, userId?: string ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, tableName, }); // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 const primaryKeyQuery = ` SELECT kcu.column_name, c.data_type FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.columns c ON kcu.column_name = c.column_name AND kcu.table_name = c.table_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' LIMIT 1 `; console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); console.log("🔍 테이블명:", tableName); const primaryKeyResult = await query<{ column_name: string; data_type: string; }>(primaryKeyQuery, [tableName]); if (!primaryKeyResult || primaryKeyResult.length === 0) { throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); } const primaryKeyInfo = primaryKeyResult[0]; const primaryKeyColumn = primaryKeyInfo.column_name; const primaryKeyDataType = primaryKeyInfo.data_type; console.log("🔑 발견된 기본키:", { column: primaryKeyColumn, dataType: primaryKeyDataType, }); // 2. 데이터 타입에 맞는 타입 캐스팅 적용 let typeCastSuffix = ""; if ( primaryKeyDataType.includes("character") || primaryKeyDataType.includes("text") ) { typeCastSuffix = "::text"; } else if ( primaryKeyDataType.includes("integer") || primaryKeyDataType.includes("bigint") ) { typeCastSuffix = "::bigint"; } else if ( primaryKeyDataType.includes("numeric") || primaryKeyDataType.includes("decimal") ) { typeCastSuffix = "::numeric"; } // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` DELETE FROM ${tableName} WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📊 SQL 파라미터:", [id]); const result = await query(deleteQuery, [id]); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); // 🔥 조건부 연결 실행 (DELETE 트리거) try { if ( companyCode && result && Array.isArray(result) && result.length > 0 ) { const deletedRecord = result[0] as Record; await EventTriggerService.executeEventTriggers( "delete", tableName, deletedRecord, companyCode ); console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)"); } } catch (triggerError) { console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); // 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행 } // 🎯 제어관리 실행 (DELETE 트리거) try { if (result && Array.isArray(result) && result.length > 0) { const deletedRecord = result[0] as Record; await this.executeDataflowControlIfConfigured( 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, "delete", userId || "system" ); } } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); // 제어관리 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행 } } catch (error) { console.error("❌ 서비스: 실제 테이블 삭제 실패:", error); throw new Error(`실제 테이블 삭제 실패: ${error}`); } } /** * 단일 폼 데이터 조회 */ async getFormData(id: number): Promise { try { console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id }); const result = await queryOne<{ id: number; screen_id: number; table_name: string; form_data: any; created_at: Date | null; updated_at: Date | null; created_by: string; updated_by: string; }>( `SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by FROM dynamic_form_data WHERE id = $1`, [id] ); if (!result) { console.log("❌ 서비스: 폼 데이터를 찾을 수 없음"); return null; } console.log("✅ 서비스: 폼 데이터 단건 조회 성공"); return { id: result.id, screenId: result.screen_id, tableName: result.table_name, data: result.form_data as Record, createdAt: result.created_at, updatedAt: result.updated_at, createdBy: result.created_by, updatedBy: result.updated_by, }; } catch (error) { console.error("❌ 서비스: 폼 데이터 단건 조회 실패:", error); throw new Error(`폼 데이터 조회 실패: ${error}`); } } /** * 화면별 폼 데이터 목록 조회 (페이징) */ async getFormDataList( screenId: number, params: { page: number; size: number; search?: string; sortBy?: string; sortOrder?: "asc" | "desc"; } ): Promise { try { console.log("📋 서비스: 폼 데이터 목록 조회 시작:", { screenId, params }); const { page, size, search, sortBy = "created_at", sortOrder = "desc", } = params; const offset = (page - 1) * size; // 정렬 컬럼 검증 (SQL Injection 방지) const allowedSortColumns = ["created_at", "updated_at", "id"]; const validSortBy = allowedSortColumns.includes(sortBy) ? sortBy : "created_at"; const validSortOrder = sortOrder === "asc" ? "ASC" : "DESC"; // 검색 조건 및 파라미터 구성 const queryParams: any[] = [screenId]; let searchCondition = ""; if (search) { searchCondition = ` AND ( form_data::text ILIKE $2 OR table_name ILIKE $2 )`; queryParams.push(`%${search}%`); } // 데이터 조회 쿼리 const dataQuery = ` SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by FROM dynamic_form_data WHERE screen_id = $1 ${searchCondition} ORDER BY ${validSortBy} ${validSortOrder} LIMIT ${size} OFFSET ${offset} `; // 전체 개수 조회 쿼리 const countQuery = ` SELECT COUNT(*) as total FROM dynamic_form_data WHERE screen_id = $1 ${searchCondition} `; // 병렬 실행 const [results, countResult] = await Promise.all([ query<{ id: number; screen_id: number; table_name: string; form_data: any; created_at: Date | null; updated_at: Date | null; created_by: string; updated_by: string; }>(dataQuery, queryParams), query<{ total: string }>(countQuery, queryParams), ]); const totalCount = parseInt(countResult[0]?.total || "0"); const formDataResults: FormDataResult[] = results.map((result) => ({ id: result.id, screenId: result.screen_id, tableName: result.table_name, data: result.form_data as Record, createdAt: result.created_at, updatedAt: result.updated_at, createdBy: result.created_by, updatedBy: result.updated_by, })); const totalPages = Math.ceil(totalCount / size); console.log("✅ 서비스: 폼 데이터 목록 조회 성공:", { totalCount, totalPages, currentPage: page, }); return { content: formDataResults, totalElements: totalCount, totalPages, currentPage: page, size, }; } catch (error) { console.error("❌ 서비스: 폼 데이터 목록 조회 실패:", error); throw new Error(`폼 데이터 목록 조회 실패: ${error}`); } } /** * 폼 데이터 검증 */ async validateFormData( tableName: string, data: Record ): Promise { try { console.log("✅ 서비스: 폼 데이터 검증 시작:", { tableName, data }); const errors: ValidationError[] = []; // 기본 검증 로직 (실제로는 테이블 스키마를 확인해야 함) Object.entries(data).forEach(([key, value]) => { // 예시: 빈 값 검증 if (value === null || value === undefined || value === "") { // 특정 필드가 required인지 확인하는 로직이 필요 // 지금은 간단히 모든 필드를 선택사항으로 처리 } // 예시: 데이터 타입 검증 // 실제로는 테이블 스키마의 컬럼 타입과 비교해야 함 }); const result: ValidationResult = { valid: errors.length === 0, errors, }; console.log("✅ 서비스: 폼 데이터 검증 완료:", result); return result; } catch (error) { console.error("❌ 서비스: 폼 데이터 검증 실패:", error); throw new Error(`폼 데이터 검증 실패: ${error}`); } } /** * 테이블 컬럼 정보 조회 (PostgreSQL 시스템 테이블 활용) */ async getTableColumns(tableName: string): Promise { try { console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName }); // PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회 const columns = await query<{ column_name: string; data_type: string; is_nullable: string; column_default: string | null; character_maximum_length: number | null; }>( `SELECT column_name, data_type, is_nullable, column_default, character_maximum_length FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`, [tableName] ); // Primary key 정보 조회 const primaryKeys = await query<{ column_name: string }>( `SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1 AND tc.table_schema = 'public'`, [tableName] ); const primaryKeyColumns = new Set( primaryKeys.map((pk) => pk.column_name) ); const result: TableColumn[] = columns.map((col) => ({ columnName: col.column_name, dataType: col.data_type, nullable: col.is_nullable === "YES", primaryKey: primaryKeyColumns.has(col.column_name), maxLength: col.character_maximum_length, defaultValue: col.column_default, })); console.log("✅ 서비스: 테이블 컬럼 정보 조회 성공:", result); return result; } catch (error) { console.error("❌ 서비스: 테이블 컬럼 정보 조회 실패:", error); throw new Error(`테이블 컬럼 정보 조회 실패: ${error}`); } } /** * 테이블의 기본키 컬럼명 목록 조회 */ async getPrimaryKeys(tableName: string): Promise { try { console.log("🔑 서비스: 테이블 기본키 조회 시작:", { tableName }); const result = await query<{ column_name: string }>( `SELECT a.attname AS column_name FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, [tableName] ); const primaryKeys = result.map((row) => row.column_name); console.log("✅ 서비스: 테이블 기본키 조회 성공:", primaryKeys); return primaryKeys; } catch (error) { console.error("❌ 서비스: 테이블 기본키 조회 실패:", error); throw new Error(`테이블 기본키 조회 실패: ${error}`); } } /** * 제어관리 실행 (화면에 설정된 경우) */ private async executeDataflowControlIfConfigured( screenId: number, tableName: string, savedData: Record, triggerType: "insert" | "update" | "delete", userId: string = "system" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); // 화면의 저장 버튼에서 제어관리 설정 조회 const screenLayouts = await query<{ component_id: string; properties: any; }>( `SELECT component_id, properties FROM screen_layouts WHERE screen_id = $1 AND component_type = $2`, [screenId, "component"] ); console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length); // 저장 버튼 중에서 제어관리가 활성화된 것 찾기 let controlConfigFound = false; for (const layout of screenLayouts) { const properties = layout.properties as any; // 디버깅: 모든 컴포넌트 정보 출력 console.log(`🔍 컴포넌트 검사:`, { componentId: layout.component_id, componentType: properties?.componentType, actionType: properties?.componentConfig?.action?.type, enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl, hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 if ( properties?.componentType === "button-primary" && properties?.componentConfig?.action?.type === "save" && properties?.webTypeConfig?.enableDataflowControl === true && properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId ) { controlConfigFound = true; const diagramId = properties.webTypeConfig.dataflowConfig.selectedDiagramId; const relationshipId = properties.webTypeConfig.dataflowConfig.selectedRelationshipId; console.log(`🎯 제어관리 설정 발견:`, { componentId: layout.component_id, diagramId, relationshipId, triggerType, }); // 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) let controlResult: any; if (!relationshipId) { // 노드 플로우 실행 console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, { sourceData: [savedData], dataSourceType: "formData", buttonId: "save-button", screenId: screenId, userId: userId, formData: savedData, }); controlResult = { success: executionResult.success, message: executionResult.message, executedActions: executionResult.nodes?.map((node) => ({ nodeId: node.nodeId, status: node.status, duration: node.duration, })), errors: executionResult.nodes ?.filter((node) => node.status === "failed") .map((node) => node.error || "실행 실패"), }; } else { // 관계 기반 제어관리 실행 console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`); controlResult = await this.dataflowControlService.executeDataflowControl( diagramId, relationshipId, triggerType, savedData, tableName, userId ); } console.log(`🎯 제어관리 실행 결과:`, controlResult); if (controlResult.success) { console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); if ( controlResult.executedActions && controlResult.executedActions.length > 0 ) { console.log(`📊 실행된 액션들:`, controlResult.executedActions); } // 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패) if (controlResult.errors && controlResult.errors.length > 0) { console.warn( `⚠️ 제어관리 실행 중 일부 오류 발생:`, controlResult.errors ); // 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능 // 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행 } } else { console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); // 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음 } // 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우) break; } } if (!controlConfigFound) { console.log(`ℹ️ 제어관리 설정이 없습니다. (화면 ID: ${screenId})`); } } catch (error) { console.error("❌ 제어관리 설정 확인 및 실행 오류:", error); // 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해 } } } // 싱글톤 인스턴스 생성 및 export export const dynamicFormService = new DynamicFormService();