import { query, queryOne, transaction, getPool } 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(); } // created_date는 항상 현재 시간으로 설정 (기존 값 무시) if (tableColumns.includes("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) { // 헤더 + 품목을 병합 // item에서 created_date 제거 (dataToInsert의 현재 시간 유지) const { created_date: _, ...itemWithoutCreatedDate } = item; const rawMergedData = { ...dataToInsert, ...itemWithoutCreatedDate, }; // 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함 // _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE) // 그 외의 경우는 모두 새 레코드로 처리 (INSERT) const isExistingRecord = rawMergedData._existingRecord === true; if (!isExistingRecord) { // 새 레코드: id 제거하여 새 UUID 자동 생성 const oldId = rawMergedData.id; delete rawMergedData.id; console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`); } else { console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`); } // 메타 플래그 제거 delete rawMergedData._isNewItem; delete rawMergedData._existingRecord; // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) 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 { // savedData 또는 insertedRecord에서 company_code 추출 const recordCompanyCode = (insertedRecord as Record)?.company_code || dataToInsert.company_code || "*"; await this.executeDataflowControlIfConfigured( screenId, tableName, insertedRecord as Record, "insert", created_by || "system", recordCompanyCode ); } 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: string | number, // 🔧 UUID 문자열도 지원 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(); } // updated_date 컬럼도 지원 (sales_order_mng 등) if (tableColumns.includes("updated_date")) { changedFields.updated_date = new Date(); console.log("📅 updated_date 자동 추가:", changedFields.updated_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 if (dataType === "jsonb" || dataType === "json") { // 🆕 JSONB/JSON 타입은 명시적 캐스팅 return `${key} = $${index + 1}::jsonb`; } else { // 문자열 타입은 캐스팅 불필요 return `${key} = $${index + 1}`; } }) .join(", "); // 🆕 JSONB 타입 값은 JSON 문자열로 변환 const values: any[] = Object.keys(changedFields).map((key) => { const value = changedFields[key]; const dataType = columnTypes[key]; // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 if ( (dataType === "jsonb" || dataType === "json") && (Array.isArray(value) || (typeof value === "object" && value !== null)) ) { return JSON.stringify(value); } return value; }); 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 { // updatedRecord에서 company_code 추출 const recordCompanyCode = (updatedRecord as Record)?.company_code || company_code || "*"; await this.executeDataflowControlIfConfigured( 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, "update", updated_by || "system", recordCompanyCode ); } 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]); // 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용) const result = await transaction(async (client) => { // 이력 트리거에서 사용할 사용자 정보 설정 if (userId) { await client.query(`SET LOCAL app.user_id = '${userId}'`); } const res = await client.query(deleteQuery, [id]); return res.rows; }); 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; // deletedRecord에서 company_code 추출 const recordCompanyCode = deletedRecord?.company_code || companyCode || "*"; await this.executeDataflowControlIfConfigured( 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, "delete", userId || "system", recordCompanyCode ); } } 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", companyCode: string = "*" ): 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, hasFlowControls: !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 if ( properties?.componentType === "button-primary" && properties?.componentConfig?.action?.type === "save" && properties?.webTypeConfig?.enableDataflowControl === true ) { const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; // 다중 제어 설정 확인 (flowControls 배열) const flowControls = dataflowConfig?.flowControls || []; // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행 if (flowControls.length > 0) { controlConfigFound = true; console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`); // 순서대로 정렬 const sortedControls = [...flowControls].sort( (a: any, b: any) => (a.order || 0) - (b.order || 0) ); // 다중 제어 순차 실행 await this.executeMultipleFlowControls( sortedControls, savedData, screenId, tableName, triggerType, userId, companyCode ); } else if (dataflowConfig?.selectedDiagramId) { // 기존 단일 제어 실행 (하위 호환성) controlConfigFound = true; const diagramId = dataflowConfig.selectedDiagramId; const relationshipId = dataflowConfig.selectedRelationshipId; console.log(`🎯 단일 제어관리 설정 발견:`, { componentId: layout.component_id, diagramId, relationshipId, triggerType, }); await this.executeSingleFlowControl( diagramId, relationshipId, savedData, screenId, tableName, triggerType, userId, companyCode ); } // 첫 번째 설정된 버튼의 제어관리만 실행 break; } } if (!controlConfigFound) { console.log(`ℹ️ 제어관리 설정이 없습니다. (화면 ID: ${screenId})`); } } catch (error) { console.error("❌ 제어관리 설정 확인 및 실행 오류:", error); // 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해 } } /** * 다중 제어 순차 실행 */ private async executeMultipleFlowControls( flowControls: Array<{ id: string; flowId: number; flowName: string; executionTiming: string; order: number; }>, savedData: Record, screenId: number, tableName: string, triggerType: "insert" | "update" | "delete", userId: string, companyCode: string ): Promise { console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`); const { NodeFlowExecutionService } = await import( "./nodeFlowExecutionService" ); const results: Array<{ order: number; flowId: number; flowName: string; success: boolean; message: string; duration: number; }> = []; for (let i = 0; i < flowControls.length; i++) { const control = flowControls[i]; const startTime = Date.now(); console.log( `\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})` ); try { // 유효하지 않은 flowId 스킵 if (!control.flowId || control.flowId <= 0) { console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`); results.push({ order: control.order, flowId: control.flowId, flowName: control.flowName, success: false, message: "유효하지 않은 flowId", duration: 0, }); continue; } const executionResult = await NodeFlowExecutionService.executeFlow( control.flowId, { sourceData: [savedData], dataSourceType: "formData", buttonId: "save-button", screenId: screenId, userId: userId, companyCode: companyCode, formData: savedData, } ); const duration = Date.now() - startTime; results.push({ order: control.order, flowId: control.flowId, flowName: control.flowName, success: executionResult.success, message: executionResult.message, duration, }); if (executionResult.success) { console.log( `✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)` ); } else { console.error( `❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}` ); // 이전 제어 실패 시 다음 제어 실행 중단 console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`); break; } } catch (error: any) { const duration = Date.now() - startTime; console.error( `❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`, error ); results.push({ order: control.order, flowId: control.flowId, flowName: control.flowName, success: false, message: error.message || "실행 오류", duration, }); // 오류 발생 시 다음 제어 실행 중단 console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`); break; } } // 실행 결과 요약 const successCount = results.filter((r) => r.success).length; const failCount = results.filter((r) => !r.success).length; const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); console.log(`\n📊 다중 제어 실행 완료:`, { total: flowControls.length, executed: results.length, success: successCount, failed: failCount, totalDuration: `${totalDuration}ms`, }); } /** * 단일 제어 실행 (기존 로직, 하위 호환성) */ private async executeSingleFlowControl( diagramId: number, relationshipId: string | null, savedData: Record, screenId: number, tableName: string, triggerType: "insert" | "update" | "delete", userId: string, companyCode: string ): Promise { 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, companyCode: companyCode, 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}`); } } /** * 특정 테이블의 특정 필드 값만 업데이트 * (다른 테이블의 레코드 업데이트 지원) */ async updateFieldValue( tableName: string, keyField: string, keyValue: any, updateField: string, updateValue: any, companyCode: string, userId: string ): Promise<{ affectedRows: number }> { const pool = getPool(); const client = await pool.connect(); try { console.log("🔄 [updateFieldValue] 업데이트 실행:", { tableName, keyField, keyValue, updateField, updateValue, companyCode, }); // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인) const columnQuery = ` SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') `; const columnResult = await client.query(columnQuery, [tableName]); const existingColumns = columnResult.rows.map( (row: any) => row.column_name ); const hasUpdatedBy = existingColumns.includes("updated_by"); const hasUpdatedAt = existingColumns.includes("updated_at"); const hasCompanyCode = existingColumns.includes("company_code"); console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { hasUpdatedBy, hasUpdatedAt, hasCompanyCode, }); // 동적 SET 절 구성 let setClause = `"${updateField}" = $1`; const params: any[] = [updateValue]; let paramIndex = 2; if (hasUpdatedBy) { setClause += `, updated_by = $${paramIndex}`; params.push(userId); paramIndex++; } if (hasUpdatedAt) { setClause += `, updated_at = NOW()`; } // WHERE 절 구성 let whereClause = `"${keyField}" = $${paramIndex}`; params.push(keyValue); paramIndex++; // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만) if (hasCompanyCode && companyCode && companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } const sqlQuery = ` UPDATE "${tableName}" SET ${setClause} WHERE ${whereClause} `; console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery); console.log("🔍 [updateFieldValue] 파라미터:", params); const result = await client.query(sqlQuery, params); console.log("✅ [updateFieldValue] 결과:", { affectedRows: result.rowCount, }); return { affectedRows: result.rowCount || 0 }; } catch (error) { console.error("❌ [updateFieldValue] 오류:", error); throw error; } finally { client.release(); } } /** * 위치 이력 저장 (연속 위치 추적용) */ async saveLocationHistory(data: { userId: string; companyCode: string; latitude: number; longitude: number; accuracy?: number; altitude?: number; speed?: number; heading?: number; tripId?: string; tripStatus?: string; departure?: string; arrival?: string; departureName?: string; destinationName?: string; recordedAt?: string; vehicleId?: number; }): Promise<{ id: number }> { const pool = getPool(); const client = await pool.connect(); try { console.log("📍 [saveLocationHistory] 저장 시작:", data); const sqlQuery = ` INSERT INTO vehicle_location_history ( user_id, company_code, latitude, longitude, accuracy, altitude, speed, heading, trip_id, trip_status, departure, arrival, departure_name, destination_name, recorded_at, vehicle_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id `; const params = [ data.userId, data.companyCode, data.latitude, data.longitude, data.accuracy || null, data.altitude || null, data.speed || null, data.heading || null, data.tripId || null, data.tripStatus || "active", data.departure || null, data.arrival || null, data.departureName || null, data.destinationName || null, data.recordedAt ? new Date(data.recordedAt) : new Date(), data.vehicleId || null, ]; const result = await client.query(sqlQuery, params); console.log("✅ [saveLocationHistory] 저장 완료:", { id: result.rows[0]?.id, }); return { id: result.rows[0]?.id }; } catch (error) { console.error("❌ [saveLocationHistory] 오류:", error); throw error; } finally { client.release(); } } /** * 위치 이력 조회 (경로 조회용) */ async getLocationHistory(params: { companyCode: string; tripId?: string; userId?: string; startDate?: string; endDate?: string; limit?: number; }): Promise { const pool = getPool(); const client = await pool.connect(); try { console.log("📍 [getLocationHistory] 조회 시작:", params); const conditions: string[] = []; const queryParams: any[] = []; let paramIndex = 1; // 멀티테넌시: company_code 필터 if (params.companyCode && params.companyCode !== "*") { conditions.push(`company_code = $${paramIndex}`); queryParams.push(params.companyCode); paramIndex++; } // trip_id 필터 if (params.tripId) { conditions.push(`trip_id = $${paramIndex}`); queryParams.push(params.tripId); paramIndex++; } // user_id 필터 if (params.userId) { conditions.push(`user_id = $${paramIndex}`); queryParams.push(params.userId); paramIndex++; } // 날짜 범위 필터 if (params.startDate) { conditions.push(`recorded_at >= $${paramIndex}`); queryParams.push(new Date(params.startDate)); paramIndex++; } if (params.endDate) { conditions.push(`recorded_at <= $${paramIndex}`); queryParams.push(new Date(params.endDate)); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; const sqlQuery = ` SELECT id, user_id, vehicle_id, latitude, longitude, accuracy, altitude, speed, heading, trip_id, trip_status, departure, arrival, departure_name, destination_name, recorded_at, created_at, company_code FROM vehicle_location_history ${whereClause} ORDER BY recorded_at ASC ${limitClause} `; console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery); console.log("🔍 [getLocationHistory] 파라미터:", queryParams); const result = await client.query(sqlQuery, queryParams); console.log("✅ [getLocationHistory] 조회 완료:", { count: result.rowCount, }); return result.rows; } catch (error) { console.error("❌ [getLocationHistory] 오류:", error); throw error; } finally { client.release(); } } } // 싱글톤 인스턴스 생성 및 export export const dynamicFormService = new DynamicFormService();