From 0728ce2e0c085b8f1e8699bcdbe3d2000208bc74 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 31 Jul 2025 09:34:29 +0000 Subject: (김준회) MDG 쿼리 배치처리 도입 (네트워크 왕복 오버헤드 해결 목적), 마이그레이션간 DELETE 제거, 환경변수 정리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/soap/batch-utils.ts | 86 ++++++++ lib/soap/mdg/utils.ts | 556 ------------------------------------------------ lib/soap/utils.ts | 556 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 642 insertions(+), 556 deletions(-) create mode 100644 lib/soap/batch-utils.ts delete mode 100644 lib/soap/mdg/utils.ts create mode 100644 lib/soap/utils.ts (limited to 'lib') diff --git a/lib/soap/batch-utils.ts b/lib/soap/batch-utils.ts new file mode 100644 index 00000000..785c85eb --- /dev/null +++ b/lib/soap/batch-utils.ts @@ -0,0 +1,86 @@ +import { inArray, sql } from "drizzle-orm"; +import db from "@/db/db"; + +/** + * 대용량 INSERT/UPSERT 및 하위 테이블 교체 처리를 위한 공통 유틸 함수 모음. + * + * - `bulkUpsert` : 단일 PK(혹은 UNIQUE) 컬럼을 기준으로 다건 UPSERT 수행 + * - `bulkReplaceSubTableData` : FK 컬럼(IN ... ) 조건으로 기존 데이터 삭제 후 신규 데이터를 배치 삽입 + * + * NOTE: Drizzle ORM 의 onConflictDoUpdate 구문은 한 번의 INSERT 문으로도 여러 레코드의 + * UPSERT 를 처리할 수 있으므로, 네트워크 왕복 횟수를 크게 줄일 수 있다. + */ + +/** + * Primary/Unique Key 하나를 기준으로 여러 레코드를 UPSERT 한다. + * @param tx Active transaction object from `db.transaction` + * @param table Drizzle table schema object + * @param data Rows to upsert + * @param uniqueCol Column name that has UNIQUE constraint (e.g. 'MATNR') + * @param chunkSize Split size to avoid exceeding Postgres parameter limit (default 1000) + */ +export async function bulkUpsert>( + tx: Parameters[0]>[0], + table: any, // Generic Drizzle table type – use `any` to stay flexible + data: T[], + uniqueCol: string, + chunkSize: number = 500, +) { + if (!data.length) return; + + // Build SET clause once, using excluded.* reference for every column except PK / createdAt / id + const buildSetClause = (sample: T) => { + const setObj: Record = { updatedAt: new Date() }; + for (const col of Object.keys(sample)) { + if (col === uniqueCol || col === "id" || col === "createdAt" || col === "updatedAt") continue; + setObj[col] = sql.raw(`excluded."${col}"`); + } + return setObj; + }; + + const setClause = buildSetClause(data[0]); + + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize); + await tx.insert(table) + .values(chunk as any) + .onConflictDoUpdate({ + target: table[uniqueCol], + set: setClause, + }); + } +} + +/** + * 여러 부모 레코드에 매핑된 하위 테이블(자식 테이블) 데이터를 한 번에 교체한다. + * 1) parentIds 에 해당하는 기존 데이터를 삭제한 뒤 + * 2) 새 데이터 배열을 배치 삽입한다. + * + * @param tx Active transaction object + * @param table Drizzle table schema object (child table) + * @param data Rows to insert after deletion + * @param parentField Column object that references the FK to parent (e.g. MATERIAL_MASTER_PART_MATL_DESC.MATNR) + * @param parentIds Parent id list to match IN (...) + * @param chunkSize Batch insert split size (default 1000) + */ +export async function bulkReplaceSubTableData>( + tx: Parameters[0]>[0], + table: any, + data: T[], + parentField: any, + parentIds: string[], + chunkSize: number = 1000, +) { + if (!parentIds.length) return; + + // 1. 기존 데이터 일괄 삭제 + await tx.delete(table).where(inArray(parentField, parentIds)); + + // 2. 새 데이터 일괄 삽입 (chunking) + if (!data.length) return; + + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize); + await tx.insert(table).values(chunk as any); + } +} diff --git a/lib/soap/mdg/utils.ts b/lib/soap/mdg/utils.ts deleted file mode 100644 index 52c82d47..00000000 --- a/lib/soap/mdg/utils.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { XMLParser } from "fast-xml-parser"; -import { readFileSync } from "fs"; -import { NextResponse } from "next/server"; -import { join } from "path"; -import { eq } from "drizzle-orm"; -import db from "@/db/db"; -import { soapLogs, type LogDirection, type SoapLogInsert } from "@/db/schema/SOAP/soap"; -import { XMLBuilder } from 'fast-xml-parser'; // for object→XML 변환 - -// XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성 -export type ToXMLFields = { - [K in keyof T]?: T[K] extends string | null | undefined ? string : never; -}; - -// SOAP Body 데이터 타입 (범용) -export interface SoapBodyData { - [key: string]: unknown; -} - -// WSDL 파일 제공 함수 -export function serveWsdl(wsdlFileName: string) { - try { - // public/wsdl 에서 WSDL 제공함을 가정 - // 이게 WSDL 구현 표준인데, 보안 감사에서 반대한다면 제거 - const wsdlPath = join(process.cwd(), 'public', 'wsdl', wsdlFileName); - const wsdlContent = readFileSync(wsdlPath, 'utf-8'); - - return new NextResponse(wsdlContent, { - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }); - } catch (error) { - console.error('Failed to read WSDL file:', error); - return new NextResponse('WSDL file not found', { status: 404 }); - } -} - -// XML 파서 생성 -// SAP XI 가 자동생성해 보내는 XML을 처리할 수 있도록 설정함 -export function createXMLParser(arrayTags: string[] = []) { - return new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - parseAttributeValue: false, - trimValues: true, - isArray: (name: string) => arrayTags.includes(name), - parseTagValue: false, - allowBooleanAttributes: true, - }); -} - -// SOAP Body나 루트에서 요청 데이터 추출 (범용) -export function extractRequestData( - parsedData: Record, - requestKeyPattern: string -): SoapBodyData | null { - // SOAP 구조 체크 (방어적) - const soapPaths = [ - ['soap:Envelope', 'soap:Body'], - ['SOAP:Envelope', 'SOAP:Body'], - ['Envelope', 'Body'], - ['soapenv:Envelope', 'soapenv:Body'] - ]; - - for (const [envelope, body] of soapPaths) { - const envelopeData = parsedData?.[envelope] as Record | undefined; - if (envelopeData?.[body]) { - const result = extractFromSoapBody(envelopeData[body] as SoapBodyData, requestKeyPattern); - if (result) return result; - } - } - - // 직접 요청 데이터 체크 - const requestKeys = [ - requestKeyPattern, - `tns:${requestKeyPattern}`, - `ns1:${requestKeyPattern}`, - `p0:${requestKeyPattern}` - ]; - - for (const key of requestKeys) { - if (parsedData?.[key]) { - return parsedData[key] as SoapBodyData; - } - } - - // 키 이름 패턴 검색 - for (const key of Object.keys(parsedData)) { - if (key.includes(requestKeyPattern)) { - return parsedData[key] as SoapBodyData; - } - } - - // 메인 데이터가 직접 있는 경우 (MATL 등) - if (parsedData?.MATL && Array.isArray(parsedData.MATL)) { - return parsedData as SoapBodyData; - } - - return null; -} - -function extractFromSoapBody(soapBody: SoapBodyData, requestKeyPattern: string): SoapBodyData | null { - const requestKeys = [ - requestKeyPattern.replace('Req', ''), - requestKeyPattern, - `tns:${requestKeyPattern}`, - `ns1:${requestKeyPattern}`, - `p0:${requestKeyPattern}` - ]; - - for (const key of requestKeys) { - if (soapBody?.[key]) { - return soapBody[key] as SoapBodyData; - } - } - - // 패턴 검색 - for (const key of Object.keys(soapBody)) { - if (key.includes(requestKeyPattern)) { - return soapBody[key] as SoapBodyData; - } - } - - // 메인 데이터가 직접 있는 경우 - if (soapBody.MATL && Array.isArray(soapBody.MATL)) { - return soapBody; - } - - return null; -} - -// 범용 XML → DB 변환 함수 -/** - * XML 데이터를 DB 삽입 가능한 형태로 변환 - * - * 아키텍처 설계: - * - 하위 테이블들은 별도의 필수 필드가 없다고 가정 (스키마에서 notNull() 제거 예정) - * - FK는 항상 최상위 테이블의 unique 필드를 참조 - * - 송신된 XML은 항상 전체 데이터셋을 포함 - * - 최상위 테이블의 unique 필드가 충돌하면 전체 삭제 후 재삽입 처리 - * - * FK 처리 방식: - * - XML에 FK 필드가 이미 포함된 경우: XML 값 우선 사용 (예: MATL 인터페이스) - * - XML에 FK 필드가 없는 경우: 상위에서 전달받은 FK 값 사용 (예: VENDOR 인터페이스) - * - 이를 통해 다양한 SAP 인터페이스 패턴에 대응 - * - * @param xmlData XML에서 파싱된 데이터 - * @param fkData 상위 테이블에서 전달받은 FK 데이터 - * @returns DB 삽입 가능한 형태로 변환된 데이터 - */ -export function convertXMLToDBData>( - xmlData: Record, - fkData?: Record -): T { - const result = {} as T; - - // XML 필드를 DB 필드로 변환 (string → string|null) - for (const key in xmlData) { - if (xmlData.hasOwnProperty(key)) { - const value = xmlData[key]; - (result as Record)[key] = value || null; - } - } - - // FK 필드 처리 (XML 우선, 없으면 상위에서 전달받은 값 사용) - if (fkData) { - for (const [key, value] of Object.entries(fkData)) { - // XML에 해당 FK 필드가 없거나 비어있는 경우에만 상위 값 사용 - const existingValue = (result as Record)[key]; - if (!existingValue || existingValue === null || existingValue === '') { - (result as Record)[key] = value; - } - // XML에 이미 FK 필드가 있고 값이 있는 경우는 XML 값을 그대로 사용 - } - } - - return result; -} - -// 중첩 배열 처리 함수 (개선된 버전) -/** - * 중첩된 배열 데이터를 처리하여 DB 삽입 가능한 형태로 변환 - * - * 처리 방식: - * - 하위 테이블 데이터는 FK만 설정하면 됨 - * - 별도의 필수 필드 생성 로직 불필요 - * - 전체 데이터셋 기반으로 삭제 후 재삽입 처리 - * - * @param items 처리할 배열 데이터 - * @param converter 변환 함수 - * @param fkData FK 데이터 - * @returns 변환된 배열 데이터 - */ -export function processNestedArray( - items: T[] | undefined, - converter: (item: T, fkData?: Record) => U, - fkData?: Record -): U[] { - if (!items || !Array.isArray(items)) { - return []; - } - - return items.map(item => converter(item, fkData)); -} - -// Helper: SOAP Envelope 빌더 -function buildSoapEnvelope(namespace: string, bodyContent: string = ''): string { - return ` - - - ${bodyContent} - -`; -} - -// Generic: JS object → XML string 변환 -function objectToXML(obj: Record): string { - const builder = new XMLBuilder({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - format: false, - suppressEmptyNode: true, - }); - return builder.build(obj); -} - -// 범용 SOAP 응답 생성 함수 -// body는 XML string이거나 JS 객체(자동으로 XML 변환) -export function createSoapResponse( - namespace: string, - body: string | Record -): NextResponse { - const bodyXml = typeof body === 'string' ? body : objectToXML(body); - return new NextResponse(buildSoapEnvelope(namespace, bodyXml), { - headers: { 'Content-Type': 'text/xml; charset=utf-8' }, - }); -} - -// 에러 응답 생성 -// 기본: 기존 SOAP Fault 유지 -// 추가: namespace & elementName 전달 시 E 구조로 응답 (100자 제한) -export function createErrorResponse( - error: unknown, - namespace?: string, - elementName?: string -): NextResponse { - console.error('API Error:', error); - - if (namespace && elementName) { - const rawMessage = error instanceof Error ? error.message : 'Unknown error'; - const truncatedMsg = rawMessage.length > 100 ? rawMessage.slice(0, 100) : rawMessage; - const body = `<${elementName}> - E - ${truncatedMsg} - `; - - return new NextResponse(buildSoapEnvelope(namespace, body), { - headers: { 'Content-Type': 'text/xml; charset=utf-8' }, - }); - } - - // Fallback: SOAP Fault (기존 호환) - const errorResponse = buildSoapEnvelope( - namespace || '', - ` - soap:Server - ${error instanceof Error ? ('[from eVCP]: ' + error.message) : 'Unknown error'} - ` - ); - - return new NextResponse(errorResponse, { - status: 500, - headers: { 'Content-Type': 'text/xml; charset=utf-8' }, - }); -} - -// 성공 응답 생성 -// 기본: Body 비어있는 기존 형태 유지 -// elementName 전달 시 EV_TYPE(S/E) 및 EV_MESSAGE 포함 -export function createSuccessResponse( - namespace: string, - elementName?: string, - evType: 'S' | 'E' = 'S', - evMessage?: string -): NextResponse { - if (elementName) { - const msgTag = evMessage ? `${evMessage}` : ''; - const body = `<${elementName}> - ${evType} - ${msgTag} - `; - return new NextResponse(buildSoapEnvelope(namespace, body), { - headers: { 'Content-Type': 'text/xml; charset=utf-8' }, - }); - } - - // 기본(빈 Body) 응답 - return new NextResponse(buildSoapEnvelope(namespace), { - headers: { 'Content-Type': 'text/xml; charset=utf-8' }, - }); -} - -// 하위 테이블 처리: FK 기준으로 전체 삭제 후 재삽입 -/** - * 하위 테이블 데이터를 전체 삭제 후 재삽입하는 함수 - * - * 처리 전략: - * - 송신 XML이 전체 데이터셋을 포함한다는 가정하에 설계 - * - 부분 업데이트보다 전체 교체를 통해 데이터 일관성 확보 - * - FK 기준으로 해당 부모 레코드의 모든 하위 데이터 교체 - * - * 처리 순서: - * 1. FK 기준으로 기존 데이터 전체 삭제 - * 2. 새로운 데이터 전체 삽입 - * - * @param tx 트랜잭션 객체 - * @param table 대상 테이블 스키마 - * @param data 삽입할 데이터 배열 - * @param parentField FK 필드명 (일반적으로 'VNDRCD') - * @param parentValue FK 값 (상위 테이블의 unique 필드 값) - */ -export async function replaceSubTableData>( - tx: Parameters[0]>[0], - table: any, // Drizzle 테이블 객체 - 복잡한 제네릭 타입으로 인해 any 사용 - data: T[], - parentField: string, - parentValue: string -) { - // 1. 기존 데이터 전체 삭제 (FK 기준) - eq() 함수 사용 - await tx.delete(table).where(eq(table[parentField], parentValue)); - - // 2. 새 데이터 삽입 - if (data.length > 0) { - await tx.insert(table).values(data); - } -} - -// ======================================== -// SOAP 로그 관련 공통 함수들 -// ======================================== - -/** - * SOAP 요청 로그를 시작하고 로그 ID를 반환 - * @param direction 수신/송신 구분 ('INBOUND' | 'OUTBOUND') - * @param system 시스템명 (예: 'S-ERP', 'MDG') - * @param interfaceName 인터페이스명 (예: 'IF_MDZ_EVCP_CUSTOMER_MASTER') - * @param requestData 요청 XML 데이터 - * @returns 생성된 로그 ID - */ -export async function startSoapLog( - direction: LogDirection, - system: string, - interfaceName: string, - requestData: string -): Promise { - try { - const logData: SoapLogInsert = { - direction, - system, - interface: interfaceName, - startedAt: new Date(), - endedAt: null, - isSuccess: false, - requestData, - responseData: null, - errorMessage: null, - }; - - const [result] = await db.insert(soapLogs).values(logData).returning({ id: soapLogs.id }); - - console.log(`📝 SOAP 로그 시작 [${direction}] ${system}/${interfaceName} - ID: ${result.id}`); - return result.id; - } catch (error) { - console.error('SOAP 로그 시작 실패:', error); - throw error; - } -} - -/** - * SOAP 요청 로그를 완료 처리 - * @param logId 로그 ID - * @param isSuccess 성공 여부 - * @param responseData 응답 XML 데이터 (선택사항) - * @param errorMessage 에러 메시지 (실패시) - */ -export async function completeSoapLog( - logId: number, - isSuccess: boolean, - responseData?: string, - errorMessage?: string -): Promise { - try { - await db.update(soapLogs) - .set({ - endedAt: new Date(), - isSuccess, - responseData: responseData || null, - errorMessage: errorMessage || null, - }) - .where(eq(soapLogs.id, logId)); - - console.log(`✅ SOAP 로그 완료 - ID: ${logId}, 성공: ${isSuccess}`); - } catch (error) { - console.error('SOAP 로그 완료 처리 실패:', error); - throw error; - } -} - -/** - * 환경변수 기반으로 오래된 SOAP 로그 정리 - * SOAP_LOG_MAX_RECORDS 환경변수를 확인하여 최대 개수 초과시 오래된 로그 삭제 - */ -export async function cleanupOldSoapLogs(): Promise { - try { - const maxRecords = parseInt(process.env.SOAP_LOG_MAX_RECORDS || '0'); - - if (maxRecords <= 0) { - console.log('🔄 SOAP 로그 정리: 무제한 저장 설정 (SOAP_LOG_MAX_RECORDS = 0)'); - return; - } - - // 현재 총 로그 개수 확인 - const totalLogs = await db.select({ count: soapLogs.id }).from(soapLogs); - const currentCount = totalLogs.length; - - if (currentCount <= maxRecords) { - console.log(`🔄 SOAP 로그 정리: 현재 ${currentCount}개, 최대 ${maxRecords}개 - 정리 불필요`); - return; - } - - // 삭제할 개수 계산 - const deleteCount = currentCount - maxRecords; - - // 가장 오래된 로그들 조회 (ID 기준) - const oldestLogs = await db.select({ id: soapLogs.id }) - .from(soapLogs) - .orderBy(soapLogs.id) - .limit(deleteCount); - - if (oldestLogs.length === 0) { - console.log('🔄 SOAP 로그 정리: 삭제할 로그 없음'); - return; - } - - // 오래된 로그들 삭제 - const oldestIds = oldestLogs.map(log => log.id); - - // 배치 삭제 (IN 절 사용) - for (const logId of oldestIds) { - await db.delete(soapLogs).where(eq(soapLogs.id, logId)); - } - - console.log(`🗑️ SOAP 로그 정리 완료: ${deleteCount}개 삭제 (${currentCount} → ${maxRecords})`); - } catch (error) { - console.error('SOAP 로그 정리 실패:', error); - throw error; - } -} - -/** - * SOAP 로그 관련 래퍼 함수: 로그 시작부터 완료까지 자동 처리 - * @param direction 수신/송신 구분 - * @param system 시스템명 - * @param interfaceName 인터페이스명 - * @param requestData 요청 데이터 - * @param processor 실제 비즈니스 로직 함수 - * @returns 처리 결과 - */ -export async function withSoapLogging( - direction: LogDirection, - system: string, - interfaceName: string, - requestData: string, - processor: () => Promise -): Promise { - let logId: number | null = null; - - try { - // 1. 로그 시작 - logId = await startSoapLog(direction, system, interfaceName, requestData); - - // 2. 실제 처리 실행 - const result = await processor(); - - // 3. 성공 로그 완료 - await completeSoapLog(logId, true); - - // 4. 로그 정리 (백그라운드) - cleanupOldSoapLogs().catch(error => - console.error('백그라운드 로그 정리 실패:', error) - ); - - return result; - - } catch (error) { - // 5. 실패 로그 완료 - if (logId !== null) { - await completeSoapLog( - logId, - false, - undefined, - error instanceof Error ? error.message : 'Unknown error' - ); - } - - throw error; - } -} - -/** - * SOAP 로그 단순 기록 함수 (이미 완료된 작업에 대한 로깅) - * @param direction 수신/송신 구분 - * @param system 시스템명 - * @param interfaceName 인터페이스명 - * @param requestData 요청 XML 데이터 - * @param responseData 응답 XML 데이터 (선택사항) - * @param isSuccess 성공 여부 - * @param errorMessage 에러 메시지 (실패시) - */ -export async function logSoapExecution( - direction: LogDirection, - system: string, - interfaceName: string, - requestData: string, - responseData?: string, - isSuccess: boolean = true, - errorMessage?: string -): Promise { - try { - const logData: SoapLogInsert = { - direction, - system, - interface: interfaceName, - startedAt: new Date(), - endedAt: new Date(), - isSuccess, - requestData, - responseData: responseData || null, - errorMessage: errorMessage || null, - }; - - await db.insert(soapLogs).values(logData); - - console.log(`📝 SOAP 로그 기록 완료 [${direction}] ${system}/${interfaceName} - 성공: ${isSuccess}`); - - // 로그 정리 (백그라운드) - cleanupOldSoapLogs().catch(error => - console.error('백그라운드 로그 정리 실패:', error) - ); - - } catch (error) { - console.error('SOAP 로그 기록 실패:', error); - // 로그 기록 실패는 메인 로직에 영향을 주지 않도록 throw 하지 않음 - } -} \ No newline at end of file diff --git a/lib/soap/utils.ts b/lib/soap/utils.ts new file mode 100644 index 00000000..52c82d47 --- /dev/null +++ b/lib/soap/utils.ts @@ -0,0 +1,556 @@ +import { XMLParser } from "fast-xml-parser"; +import { readFileSync } from "fs"; +import { NextResponse } from "next/server"; +import { join } from "path"; +import { eq } from "drizzle-orm"; +import db from "@/db/db"; +import { soapLogs, type LogDirection, type SoapLogInsert } from "@/db/schema/SOAP/soap"; +import { XMLBuilder } from 'fast-xml-parser'; // for object→XML 변환 + +// XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성 +export type ToXMLFields = { + [K in keyof T]?: T[K] extends string | null | undefined ? string : never; +}; + +// SOAP Body 데이터 타입 (범용) +export interface SoapBodyData { + [key: string]: unknown; +} + +// WSDL 파일 제공 함수 +export function serveWsdl(wsdlFileName: string) { + try { + // public/wsdl 에서 WSDL 제공함을 가정 + // 이게 WSDL 구현 표준인데, 보안 감사에서 반대한다면 제거 + const wsdlPath = join(process.cwd(), 'public', 'wsdl', wsdlFileName); + const wsdlContent = readFileSync(wsdlPath, 'utf-8'); + + return new NextResponse(wsdlContent, { + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }); + } catch (error) { + console.error('Failed to read WSDL file:', error); + return new NextResponse('WSDL file not found', { status: 404 }); + } +} + +// XML 파서 생성 +// SAP XI 가 자동생성해 보내는 XML을 처리할 수 있도록 설정함 +export function createXMLParser(arrayTags: string[] = []) { + return new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + parseAttributeValue: false, + trimValues: true, + isArray: (name: string) => arrayTags.includes(name), + parseTagValue: false, + allowBooleanAttributes: true, + }); +} + +// SOAP Body나 루트에서 요청 데이터 추출 (범용) +export function extractRequestData( + parsedData: Record, + requestKeyPattern: string +): SoapBodyData | null { + // SOAP 구조 체크 (방어적) + const soapPaths = [ + ['soap:Envelope', 'soap:Body'], + ['SOAP:Envelope', 'SOAP:Body'], + ['Envelope', 'Body'], + ['soapenv:Envelope', 'soapenv:Body'] + ]; + + for (const [envelope, body] of soapPaths) { + const envelopeData = parsedData?.[envelope] as Record | undefined; + if (envelopeData?.[body]) { + const result = extractFromSoapBody(envelopeData[body] as SoapBodyData, requestKeyPattern); + if (result) return result; + } + } + + // 직접 요청 데이터 체크 + const requestKeys = [ + requestKeyPattern, + `tns:${requestKeyPattern}`, + `ns1:${requestKeyPattern}`, + `p0:${requestKeyPattern}` + ]; + + for (const key of requestKeys) { + if (parsedData?.[key]) { + return parsedData[key] as SoapBodyData; + } + } + + // 키 이름 패턴 검색 + for (const key of Object.keys(parsedData)) { + if (key.includes(requestKeyPattern)) { + return parsedData[key] as SoapBodyData; + } + } + + // 메인 데이터가 직접 있는 경우 (MATL 등) + if (parsedData?.MATL && Array.isArray(parsedData.MATL)) { + return parsedData as SoapBodyData; + } + + return null; +} + +function extractFromSoapBody(soapBody: SoapBodyData, requestKeyPattern: string): SoapBodyData | null { + const requestKeys = [ + requestKeyPattern.replace('Req', ''), + requestKeyPattern, + `tns:${requestKeyPattern}`, + `ns1:${requestKeyPattern}`, + `p0:${requestKeyPattern}` + ]; + + for (const key of requestKeys) { + if (soapBody?.[key]) { + return soapBody[key] as SoapBodyData; + } + } + + // 패턴 검색 + for (const key of Object.keys(soapBody)) { + if (key.includes(requestKeyPattern)) { + return soapBody[key] as SoapBodyData; + } + } + + // 메인 데이터가 직접 있는 경우 + if (soapBody.MATL && Array.isArray(soapBody.MATL)) { + return soapBody; + } + + return null; +} + +// 범용 XML → DB 변환 함수 +/** + * XML 데이터를 DB 삽입 가능한 형태로 변환 + * + * 아키텍처 설계: + * - 하위 테이블들은 별도의 필수 필드가 없다고 가정 (스키마에서 notNull() 제거 예정) + * - FK는 항상 최상위 테이블의 unique 필드를 참조 + * - 송신된 XML은 항상 전체 데이터셋을 포함 + * - 최상위 테이블의 unique 필드가 충돌하면 전체 삭제 후 재삽입 처리 + * + * FK 처리 방식: + * - XML에 FK 필드가 이미 포함된 경우: XML 값 우선 사용 (예: MATL 인터페이스) + * - XML에 FK 필드가 없는 경우: 상위에서 전달받은 FK 값 사용 (예: VENDOR 인터페이스) + * - 이를 통해 다양한 SAP 인터페이스 패턴에 대응 + * + * @param xmlData XML에서 파싱된 데이터 + * @param fkData 상위 테이블에서 전달받은 FK 데이터 + * @returns DB 삽입 가능한 형태로 변환된 데이터 + */ +export function convertXMLToDBData>( + xmlData: Record, + fkData?: Record +): T { + const result = {} as T; + + // XML 필드를 DB 필드로 변환 (string → string|null) + for (const key in xmlData) { + if (xmlData.hasOwnProperty(key)) { + const value = xmlData[key]; + (result as Record)[key] = value || null; + } + } + + // FK 필드 처리 (XML 우선, 없으면 상위에서 전달받은 값 사용) + if (fkData) { + for (const [key, value] of Object.entries(fkData)) { + // XML에 해당 FK 필드가 없거나 비어있는 경우에만 상위 값 사용 + const existingValue = (result as Record)[key]; + if (!existingValue || existingValue === null || existingValue === '') { + (result as Record)[key] = value; + } + // XML에 이미 FK 필드가 있고 값이 있는 경우는 XML 값을 그대로 사용 + } + } + + return result; +} + +// 중첩 배열 처리 함수 (개선된 버전) +/** + * 중첩된 배열 데이터를 처리하여 DB 삽입 가능한 형태로 변환 + * + * 처리 방식: + * - 하위 테이블 데이터는 FK만 설정하면 됨 + * - 별도의 필수 필드 생성 로직 불필요 + * - 전체 데이터셋 기반으로 삭제 후 재삽입 처리 + * + * @param items 처리할 배열 데이터 + * @param converter 변환 함수 + * @param fkData FK 데이터 + * @returns 변환된 배열 데이터 + */ +export function processNestedArray( + items: T[] | undefined, + converter: (item: T, fkData?: Record) => U, + fkData?: Record +): U[] { + if (!items || !Array.isArray(items)) { + return []; + } + + return items.map(item => converter(item, fkData)); +} + +// Helper: SOAP Envelope 빌더 +function buildSoapEnvelope(namespace: string, bodyContent: string = ''): string { + return ` + + + ${bodyContent} + +`; +} + +// Generic: JS object → XML string 변환 +function objectToXML(obj: Record): string { + const builder = new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + format: false, + suppressEmptyNode: true, + }); + return builder.build(obj); +} + +// 범용 SOAP 응답 생성 함수 +// body는 XML string이거나 JS 객체(자동으로 XML 변환) +export function createSoapResponse( + namespace: string, + body: string | Record +): NextResponse { + const bodyXml = typeof body === 'string' ? body : objectToXML(body); + return new NextResponse(buildSoapEnvelope(namespace, bodyXml), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); +} + +// 에러 응답 생성 +// 기본: 기존 SOAP Fault 유지 +// 추가: namespace & elementName 전달 시 E 구조로 응답 (100자 제한) +export function createErrorResponse( + error: unknown, + namespace?: string, + elementName?: string +): NextResponse { + console.error('API Error:', error); + + if (namespace && elementName) { + const rawMessage = error instanceof Error ? error.message : 'Unknown error'; + const truncatedMsg = rawMessage.length > 100 ? rawMessage.slice(0, 100) : rawMessage; + const body = `<${elementName}> + E + ${truncatedMsg} + `; + + return new NextResponse(buildSoapEnvelope(namespace, body), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); + } + + // Fallback: SOAP Fault (기존 호환) + const errorResponse = buildSoapEnvelope( + namespace || '', + ` + soap:Server + ${error instanceof Error ? ('[from eVCP]: ' + error.message) : 'Unknown error'} + ` + ); + + return new NextResponse(errorResponse, { + status: 500, + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); +} + +// 성공 응답 생성 +// 기본: Body 비어있는 기존 형태 유지 +// elementName 전달 시 EV_TYPE(S/E) 및 EV_MESSAGE 포함 +export function createSuccessResponse( + namespace: string, + elementName?: string, + evType: 'S' | 'E' = 'S', + evMessage?: string +): NextResponse { + if (elementName) { + const msgTag = evMessage ? `${evMessage}` : ''; + const body = `<${elementName}> + ${evType} + ${msgTag} + `; + return new NextResponse(buildSoapEnvelope(namespace, body), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); + } + + // 기본(빈 Body) 응답 + return new NextResponse(buildSoapEnvelope(namespace), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); +} + +// 하위 테이블 처리: FK 기준으로 전체 삭제 후 재삽입 +/** + * 하위 테이블 데이터를 전체 삭제 후 재삽입하는 함수 + * + * 처리 전략: + * - 송신 XML이 전체 데이터셋을 포함한다는 가정하에 설계 + * - 부분 업데이트보다 전체 교체를 통해 데이터 일관성 확보 + * - FK 기준으로 해당 부모 레코드의 모든 하위 데이터 교체 + * + * 처리 순서: + * 1. FK 기준으로 기존 데이터 전체 삭제 + * 2. 새로운 데이터 전체 삽입 + * + * @param tx 트랜잭션 객체 + * @param table 대상 테이블 스키마 + * @param data 삽입할 데이터 배열 + * @param parentField FK 필드명 (일반적으로 'VNDRCD') + * @param parentValue FK 값 (상위 테이블의 unique 필드 값) + */ +export async function replaceSubTableData>( + tx: Parameters[0]>[0], + table: any, // Drizzle 테이블 객체 - 복잡한 제네릭 타입으로 인해 any 사용 + data: T[], + parentField: string, + parentValue: string +) { + // 1. 기존 데이터 전체 삭제 (FK 기준) - eq() 함수 사용 + await tx.delete(table).where(eq(table[parentField], parentValue)); + + // 2. 새 데이터 삽입 + if (data.length > 0) { + await tx.insert(table).values(data); + } +} + +// ======================================== +// SOAP 로그 관련 공통 함수들 +// ======================================== + +/** + * SOAP 요청 로그를 시작하고 로그 ID를 반환 + * @param direction 수신/송신 구분 ('INBOUND' | 'OUTBOUND') + * @param system 시스템명 (예: 'S-ERP', 'MDG') + * @param interfaceName 인터페이스명 (예: 'IF_MDZ_EVCP_CUSTOMER_MASTER') + * @param requestData 요청 XML 데이터 + * @returns 생성된 로그 ID + */ +export async function startSoapLog( + direction: LogDirection, + system: string, + interfaceName: string, + requestData: string +): Promise { + try { + const logData: SoapLogInsert = { + direction, + system, + interface: interfaceName, + startedAt: new Date(), + endedAt: null, + isSuccess: false, + requestData, + responseData: null, + errorMessage: null, + }; + + const [result] = await db.insert(soapLogs).values(logData).returning({ id: soapLogs.id }); + + console.log(`📝 SOAP 로그 시작 [${direction}] ${system}/${interfaceName} - ID: ${result.id}`); + return result.id; + } catch (error) { + console.error('SOAP 로그 시작 실패:', error); + throw error; + } +} + +/** + * SOAP 요청 로그를 완료 처리 + * @param logId 로그 ID + * @param isSuccess 성공 여부 + * @param responseData 응답 XML 데이터 (선택사항) + * @param errorMessage 에러 메시지 (실패시) + */ +export async function completeSoapLog( + logId: number, + isSuccess: boolean, + responseData?: string, + errorMessage?: string +): Promise { + try { + await db.update(soapLogs) + .set({ + endedAt: new Date(), + isSuccess, + responseData: responseData || null, + errorMessage: errorMessage || null, + }) + .where(eq(soapLogs.id, logId)); + + console.log(`✅ SOAP 로그 완료 - ID: ${logId}, 성공: ${isSuccess}`); + } catch (error) { + console.error('SOAP 로그 완료 처리 실패:', error); + throw error; + } +} + +/** + * 환경변수 기반으로 오래된 SOAP 로그 정리 + * SOAP_LOG_MAX_RECORDS 환경변수를 확인하여 최대 개수 초과시 오래된 로그 삭제 + */ +export async function cleanupOldSoapLogs(): Promise { + try { + const maxRecords = parseInt(process.env.SOAP_LOG_MAX_RECORDS || '0'); + + if (maxRecords <= 0) { + console.log('🔄 SOAP 로그 정리: 무제한 저장 설정 (SOAP_LOG_MAX_RECORDS = 0)'); + return; + } + + // 현재 총 로그 개수 확인 + const totalLogs = await db.select({ count: soapLogs.id }).from(soapLogs); + const currentCount = totalLogs.length; + + if (currentCount <= maxRecords) { + console.log(`🔄 SOAP 로그 정리: 현재 ${currentCount}개, 최대 ${maxRecords}개 - 정리 불필요`); + return; + } + + // 삭제할 개수 계산 + const deleteCount = currentCount - maxRecords; + + // 가장 오래된 로그들 조회 (ID 기준) + const oldestLogs = await db.select({ id: soapLogs.id }) + .from(soapLogs) + .orderBy(soapLogs.id) + .limit(deleteCount); + + if (oldestLogs.length === 0) { + console.log('🔄 SOAP 로그 정리: 삭제할 로그 없음'); + return; + } + + // 오래된 로그들 삭제 + const oldestIds = oldestLogs.map(log => log.id); + + // 배치 삭제 (IN 절 사용) + for (const logId of oldestIds) { + await db.delete(soapLogs).where(eq(soapLogs.id, logId)); + } + + console.log(`🗑️ SOAP 로그 정리 완료: ${deleteCount}개 삭제 (${currentCount} → ${maxRecords})`); + } catch (error) { + console.error('SOAP 로그 정리 실패:', error); + throw error; + } +} + +/** + * SOAP 로그 관련 래퍼 함수: 로그 시작부터 완료까지 자동 처리 + * @param direction 수신/송신 구분 + * @param system 시스템명 + * @param interfaceName 인터페이스명 + * @param requestData 요청 데이터 + * @param processor 실제 비즈니스 로직 함수 + * @returns 처리 결과 + */ +export async function withSoapLogging( + direction: LogDirection, + system: string, + interfaceName: string, + requestData: string, + processor: () => Promise +): Promise { + let logId: number | null = null; + + try { + // 1. 로그 시작 + logId = await startSoapLog(direction, system, interfaceName, requestData); + + // 2. 실제 처리 실행 + const result = await processor(); + + // 3. 성공 로그 완료 + await completeSoapLog(logId, true); + + // 4. 로그 정리 (백그라운드) + cleanupOldSoapLogs().catch(error => + console.error('백그라운드 로그 정리 실패:', error) + ); + + return result; + + } catch (error) { + // 5. 실패 로그 완료 + if (logId !== null) { + await completeSoapLog( + logId, + false, + undefined, + error instanceof Error ? error.message : 'Unknown error' + ); + } + + throw error; + } +} + +/** + * SOAP 로그 단순 기록 함수 (이미 완료된 작업에 대한 로깅) + * @param direction 수신/송신 구분 + * @param system 시스템명 + * @param interfaceName 인터페이스명 + * @param requestData 요청 XML 데이터 + * @param responseData 응답 XML 데이터 (선택사항) + * @param isSuccess 성공 여부 + * @param errorMessage 에러 메시지 (실패시) + */ +export async function logSoapExecution( + direction: LogDirection, + system: string, + interfaceName: string, + requestData: string, + responseData?: string, + isSuccess: boolean = true, + errorMessage?: string +): Promise { + try { + const logData: SoapLogInsert = { + direction, + system, + interface: interfaceName, + startedAt: new Date(), + endedAt: new Date(), + isSuccess, + requestData, + responseData: responseData || null, + errorMessage: errorMessage || null, + }; + + await db.insert(soapLogs).values(logData); + + console.log(`📝 SOAP 로그 기록 완료 [${direction}] ${system}/${interfaceName} - 성공: ${isSuccess}`); + + // 로그 정리 (백그라운드) + cleanupOldSoapLogs().catch(error => + console.error('백그라운드 로그 정리 실패:', error) + ); + + } catch (error) { + console.error('SOAP 로그 기록 실패:', error); + // 로그 기록 실패는 메인 로직에 영향을 주지 않도록 throw 하지 않음 + } +} \ No newline at end of file -- cgit v1.2.3