diff options
20 files changed, 563 insertions, 392 deletions
diff --git a/.env.development b/.env.development index aebc2b73..c7bf50b2 100644 --- a/.env.development +++ b/.env.development @@ -49,6 +49,10 @@ SEDP_API_PASSWORD=evcpusr@2025 ORACLE_USER=shievcp ORACLE_PASSWORD=evp_2025 ORACLE_CONNECTION_STRING=60.100.89.191:7971/SEVMQ +# NON-SAP 인코텀즈, 지불조건, 선적지, 하역지 동기화 관련 +PROCUREMENT_SYNC_ON_START=false + + # 기본 DOLCE 동기화 값 (60.100.99.217=dolce 개발, 60.100.98.68=dolce 운영) SYNC_DOLCE_URL=http://60.100.99.217:1111/ @@ -65,10 +69,10 @@ SYNC_SWP_ENABLED=true # DOLCE 설정 IMPORT_DOLCE_ENABLED=true -DOLCE_API_URL=http://60.100.99.217:1111 -DOLCE_UPLOAD_URL=http://60.100.99.217:1111/PWPUploadService.ashx -DOLCE_DOC_LIST_API_URL=http://60.100.99.217:1111/Services/VDCSWebService.svc/DwgReceiptMgmt -DOLCE_DOC_DETAIL_API_URL=http://60.100.99.217:1111/Services/VDCSWebService.svc/DetailDwgReceiptMgmt +DOLCE_API_URL="http://60.100.99.217:1111" +DOLCE_UPLOAD_URL="http://60.100.99.217:1111/PWPUploadService.ashx" +DOLCE_DOC_LIST_API_URL="http://60.100.99.217:1111/Services/VDCSWebService.svc/DwgReceiptMgmt" +DOLCE_DOC_DETAIL_API_URL="http://60.100.99.217:1111/Services/VDCSWebService.svc/DetailDwgReceiptMgmt" DOLCE_FILE_INFO_API_URL=http://60.100.99.217:1111/Services/VDCSWebService.svc/FileInfoList DOLCE_DOWNLOAD_URL=http://60.100.99.217:1111/Download.aspx @@ -135,4 +139,7 @@ MESSENGER_BASE_URL="https://openapi.samsung.net" # NAS_PATH="/evcp_nas" # 기존 개발에서는 선언되지 않은 변수라 주석 처리 # === 임시 환경변수, 나프로 요구사항 해소되면 삭제 === -READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함
\ No newline at end of file +READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함 + +# === 디버그 로깅 (lib/debug-utils.ts) === +NEXT_PUBLIC_DEBUG=true
\ No newline at end of file diff --git a/.env.production b/.env.production index c135e83b..28817a23 100644 --- a/.env.production +++ b/.env.production @@ -49,6 +49,8 @@ SEDP_API_PASSWORD=evcpusr@2025 ORACLE_USER=shievcp ORACLE_PASSWORD=evp_2025 ORACLE_CONNECTION_STRING=60.100.89.191:7971/SEVMQ +# NON-SAP 인코텀즈, 지불조건, 선적지, 하역지 동기화 관련 +PROCUREMENT_SYNC_ON_START=false # 기본 DOLCE 동기화 값 (60.100.99.217=dolce 개발, 60.100.98.68=dolce 운영) SYNC_DOLCE_URL=http://60.100.99.217:1111/ @@ -135,4 +137,7 @@ MESSENGER_BASE_URL="https://openapi.samsung.net" NAS_PATH="/evcp_nas" # === 임시 환경변수, 나프로 요구사항 해소되면 삭제 === -READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함
\ No newline at end of file +READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함 + +# === 디버그 로깅 (lib/debug-utils.ts) === +NEXT_PUBLIC_DEBUG=false
\ No newline at end of file @@ -71,4 +71,5 @@ next-env.d.ts # 개발 편의상 사용한 복사파일들 -/lib/rfqs copy
\ No newline at end of file +/lib/rfqs copy +_info/
\ No newline at end of file diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts index ba2849a3..f5689be2 100644 --- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts +++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts @@ -1,368 +1,179 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { XMLParser } from 'fast-xml-parser'; +import { NextRequest } from 'next/server'; import db from '@/db/db'; -import { biddingProjects, projectSeries, NewBiddingProject, NewProjectSeries } from '@/db/schema/projects'; -import { eq, and } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { biddingProjects, projectSeries } from '@/db/schema/projects'; +import { + ToXMLFields, + serveWsdl, + createXMLParser, + extractRequestData, + convertXMLToDBData, + createSoapResponse, + withSoapLogging, +} from '@/lib/soap/utils'; +import { + bulkUpsert, + bulkReplaceSubTableData +} from "@/lib/soap/batch-utils"; -// 요청 데이터 인터페이스 정의 -interface RequestData { - biddingProjects: BiddingProject[]; - projectSeries: ProjectSeries[]; -} +// 스키마에서 타입 추론 +type BiddingProjectData = typeof biddingProjects.$inferInsert; +type ProjectSeriesData = typeof projectSeries.$inferInsert; -interface BiddingProject { - pspid: string; - projNm?: string; - sector?: string; - projMsrm?: string; - kunnr?: string; - kunnrNm?: string; - cls1?: string; - cls1Nm?: string; - ptype?: string; - ptypeNm?: string; - pmodelCd?: string; - pmodelNm?: string; - pmodelSz?: string; - pmodelUom?: string; - txt04?: string; - txt30?: string; - estmPm?: string; - shipMHull?: string; // SHIP(조선), HULL(해양) 구분으로, 조선일 경우 NULL, 해양일 경우 'X' -} +// XML 구조 타입 정의 (XML에서 오는 추가 필드 포함) +type BiddingProjectXML = ToXMLFields<Omit<BiddingProjectData, 'id' | 'createdAt' | 'updatedAt'>> & { + shipMHull?: string; // XML에서 오는 추가 필드 +}; +type ProjectSeriesXML = ToXMLFields<Omit<ProjectSeriesData, 'id' | 'createdAt' | 'updatedAt'>>; -interface ProjectSeries { - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; -} - -// WSDL 파일 제공 헬퍼 함수 -function serveWsdl() { - try { - const wsdlPath = join(process.cwd(), 'public', 'wsdl', 'IF_ECC_EVCP_BIDDING_PROJECT.wsdl'); - 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 }); - } +// 처리된 데이터 구조 +interface ProcessedBiddingData { + biddingProjects: BiddingProjectData[]; + projectSeries: ProjectSeriesData[]; } +// GET 요청 처리는 ?wsdl 달고 있으면 WSDL 서비스 제공 export async function GET(request: NextRequest) { const url = new URL(request.url); if (url.searchParams.has('wsdl')) { - return serveWsdl(); + return serveWsdl('IF_ECC_EVCP_BIDDING_PROJECT.wsdl'); } - - // 다른 GET 요청에 대한 처리는 405 Method Not Allowed 반환 - return new NextResponse('Method Not Allowed', { status: 405 }); + + return new Response('Method Not Allowed', { status: 405 }); } +// POST 요청이 데이터 적재 요구 (SOAP) export async function POST(request: NextRequest) { - // WSDL 요청 처리 const url = new URL(request.url); if (url.searchParams.has('wsdl')) { - return serveWsdl(); + return serveWsdl('IF_ECC_EVCP_BIDDING_PROJECT.wsdl'); } - try { - // 요청 본문 가져오기 - const body = await request.text(); - - // 요청 로깅 - console.log('Request Body 일부:', body.substring(0, 200) + (body.length > 200 ? '...' : '')); - - // XML 파서 설정 - const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - parseAttributeValue: false, // 문자열 값을 변환하지 않음 (숫자 등으로) - trimValues: true, // 앞뒤 공백 제거는 유지 - isArray: (name: string) => { - // biddingProjects와 projectSeries는 항상 배열로 처리 - return ['biddingProjects', 'projectSeries'].includes(name); - }, - parseTagValue: false, // 태그 값도 타입 변환하지 않음 - allowBooleanAttributes: true - }); - - // XML 파싱 - const parsedData = parser.parse(body); - - // 디버깅용 - 최상위 구조 확인 - console.log('XML root keys:', Object.keys(parsedData)); - - // XML 구조에서 실제 데이터 추출 - let requestData = null; - - // 가능한 경로 확인 - if (parsedData?.['soap:Envelope']?.['soap:Body']) { - const soapBody = parsedData['soap:Envelope']['soap:Body']; - requestData = extractRequestData(soapBody); - } else if (parsedData?.['SOAP:Envelope']?.['SOAP:Body']) { - const soapBody = parsedData['SOAP:Envelope']['SOAP:Body']; - requestData = extractRequestData(soapBody); - } else if (parsedData?.['Envelope']?.['Body']) { - const soapBody = parsedData['Envelope']['Body']; - requestData = extractRequestData(soapBody); - } else if (parsedData?.['soapenv:Envelope']?.['soapenv:Body']) { - const soapBody = parsedData['soapenv:Envelope']['soapenv:Body']; - requestData = extractRequestData(soapBody); - } else if (parsedData?.['IF_ECC_EVCP_BIDDING_PROJECTReq']) { - requestData = parsedData['IF_ECC_EVCP_BIDDING_PROJECTReq']; - console.log('Found direct IF_ECC_EVCP_BIDDING_PROJECTReq data'); - } else if (parsedData?.['ns1:IF_ECC_EVCP_BIDDING_PROJECTReq']) { - requestData = parsedData['ns1:IF_ECC_EVCP_BIDDING_PROJECTReq']; - console.log('Found direct ns1:IF_ECC_EVCP_BIDDING_PROJECTReq data'); - } else { - // 루트 레벨에서 biddingProjects 또는 projectSeries를 직접 찾기 - if (parsedData?.biddingProjects || parsedData?.projectSeries) { - requestData = parsedData; - console.log('Found data at root level'); - } else { - // 다른 모든 키에 대해 확인 - for (const key of Object.keys(parsedData)) { - const value = parsedData[key]; - // 데이터 구조가 맞는지 확인 (biddingProjects나 projectSeries가 있는지) - if (value && (value.biddingProjects || value.projectSeries)) { - requestData = value; - console.log(`Found data in root key: ${key}`); - break; - } - - // 키 이름에 IF_ECC_EVCP_BIDDING_PROJECTReq가 포함되어 있는지 확인 - if (key.includes('IF_ECC_EVCP_BIDDING_PROJECTReq')) { - requestData = value; - console.log(`Found data in root key with matching name: ${key}`); - break; - } - } + const body = await request.text(); + + // SOAP 로깅 래퍼 함수 사용 + return withSoapLogging( + 'INBOUND', + 'ECC', + 'IF_ECC_EVCP_BIDDING_PROJECT', + body, + async () => { + console.log('🚀 BIDDING_PROJECT 수신 시작, 데이터 길이:', body.length); + + // 1) XML 파싱 + const parser = createXMLParser(['biddingProjects', 'projectSeries']); + const parsedData = parser.parse(body); + + // 2) SOAP Body 또는 루트에서 요청 데이터 추출 + const requestData = extractRequestData(parsedData, 'IF_ECC_EVCP_BIDDING_PROJECTReq'); + if (!requestData) { + console.error('유효한 요청 데이터를 찾을 수 없습니다'); + throw new Error('Missing request data - IF_ECC_EVCP_BIDDING_PROJECTReq not found'); } - } - - if (!requestData) { - console.error('Could not find valid request data in the received payload'); - console.error('Received XML structure:', JSON.stringify(parsedData, null, 2)); - throw new Error('Missing request data - could not find IF_ECC_EVCP_BIDDING_PROJECTReq or project data'); - } - - // 데이터 유효성 검증 - console.log('Validating request data structure:', - `biddingProjects: ${requestData.biddingProjects ? 'found' : 'not found'}`, - `projectSeries: ${requestData.projectSeries ? 'found' : 'not found'}` - ); - - // 샘플 데이터 로깅 - if (requestData.biddingProjects && Array.isArray(requestData.biddingProjects) && requestData.biddingProjects.length > 0) { - console.log('First biddingProject sample:', JSON.stringify(requestData.biddingProjects[0], null, 2)); - } - - if (requestData.projectSeries && Array.isArray(requestData.projectSeries) && requestData.projectSeries.length > 0) { - console.log('First projectSeries sample:', JSON.stringify(requestData.projectSeries[0], null, 2)); - } - - // 데이터 구조 정규화 - const normalizedData: RequestData = { - biddingProjects: Array.isArray(requestData.biddingProjects) - ? requestData.biddingProjects - : requestData.biddingProjects ? [requestData.biddingProjects] : [], - projectSeries: Array.isArray(requestData.projectSeries) - ? requestData.projectSeries - : requestData.projectSeries ? [requestData.projectSeries] : [] - }; - - // 기본 유효성 검사 - 필수 필드 확인 - for (const project of normalizedData.biddingProjects) { - if (!project.pspid) { - throw new Error('Missing required field: pspid in biddingProjects'); + + // 3) XML 데이터를 DB 삽입 가능한 형태로 변환 + const processedData = transformBiddingData(requestData as BiddingRequestXML); + + // 4) 필수 필드 검증 + for (const project of processedData.biddingProjects) { + if (!project.pspid) { + throw new Error('Missing required field: pspid in Bidding Project'); + } } - } - - for (const series of normalizedData.projectSeries) { - if (!series.pspid || !series.sersNo) { - throw new Error('Missing required fields in projectSeries: pspid or sersNo'); + for (const series of processedData.projectSeries) { + if (!series.pspid || !series.sersNo) { + throw new Error('Missing required fields in Project Series: pspid, sersNo'); + } } + + // 5) 데이터베이스 저장 + await saveToDatabase(processedData); + + console.log(`🎉 처리 완료: ${processedData.biddingProjects.length}개 프로젝트, ${processedData.projectSeries.length}개 시리즈`); + + // 6) 성공 응답 반환 + return createSoapResponse('http://60.101.108.100/', { + 'tns:IF_ECC_EVCP_BIDDING_PROJECTRes': { + EV_TYPE: 'S', + }, + }); } - - // 데이터베이스 저장 - await saveToDatabase(normalizedData); - - console.log(`Processed ${normalizedData.biddingProjects.length} bidding projects and ${normalizedData.projectSeries.length} project series`); - - // XML 응답 생성 - const xmlResponse = `<?xml version="1.0" encoding="UTF-8"?> -<soap:Envelope - xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" - xmlns:tns="http://60.101.108.100/"> - <soap:Body> - </soap:Body> -</soap:Envelope>`; - - return new NextResponse(xmlResponse, { - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }); - } catch (error: unknown) { - console.error('API Error:', error); - - // XML 에러 응답 - const errorResponse = `<?xml version="1.0" encoding="UTF-8"?> -<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> - <soap:Body> - <soap:Fault> - <faultcode>soap:Server</faultcode> - <faultstring>${error instanceof Error ? ('[from eVCP]: ' + error.message) : 'Unknown error'}</faultstring> - </soap:Fault> - </soap:Body> -</soap:Envelope>`; - - return new NextResponse(errorResponse, { - status: 500, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', + ).catch((error) => { + // withSoapLogging에서 이미 에러 로그를 처리하므로, 여기서는 응답만 생성 + return createSoapResponse('http://60.101.108.100/', { + 'tns:IF_ECC_EVCP_BIDDING_PROJECTRes': { + EV_TYPE: 'E', + EV_MESSAGE: + error instanceof Error ? error.message.slice(0, 100) : 'Unknown error', }, }); - } + }); } -// SOAP Body나 루트에서 요청 데이터 추출하는 헬퍼 함수 -function extractRequestData(data: Record<string, unknown>): Record<string, unknown> | null { - if (!data) return null; - - if (data['IF_ECC_EVCP_BIDDING_PROJECTReq']) { - return data['IF_ECC_EVCP_BIDDING_PROJECTReq'] as Record<string, unknown>; - } else if (data['tns:IF_ECC_EVCP_BIDDING_PROJECTReq']) { - return data['tns:IF_ECC_EVCP_BIDDING_PROJECTReq'] as Record<string, unknown>; - } else if (data['ns1:IF_ECC_EVCP_BIDDING_PROJECTReq']) { - return data['ns1:IF_ECC_EVCP_BIDDING_PROJECTReq'] as Record<string, unknown>; - } - - // 다른 키 검색 - for (const key of Object.keys(data)) { - if (key.includes('IF_ECC_EVCP_BIDDING_PROJECTReq')) { - return data[key] as Record<string, unknown>; +// ----------------------------------------------------------------------------- +// 데이터 변환 및 저장 관련 유틸리티 +// ----------------------------------------------------------------------------- + +// Root XML Request 타입 +type BiddingRequestXML = { + biddingProjects?: BiddingProjectXML[]; + projectSeries?: ProjectSeriesXML[]; +}; + +// XML -> DB 데이터 변환 함수 +function transformBiddingData(requestData: BiddingRequestXML): ProcessedBiddingData { + const projects = requestData.biddingProjects || []; + const series = requestData.projectSeries || []; + + // Bidding Projects 변환 + const biddingProjectsConverted = projects.map((project) => { + const converted = convertXMLToDBData<BiddingProjectData>( + project as Record<string, string | undefined>, + undefined + ); + + // shipMHull 값을 기준으로 pjtType 결정 + if (project.shipMHull) { + converted.pjtType = 'HULL'; // shipMHull 값이 존재하면 해양(HULL) + } else { + converted.pjtType = 'SHIP'; // shipMHull 값이 없으면 조선(SHIP) } - } - - // biddingProjects나 projectSeries가 직접 있는 경우 - if ( - (data.biddingProjects && typeof data.biddingProjects === 'object') || - (data.projectSeries && typeof data.projectSeries === 'object') - ) { - return data; - } - - return null; + + return converted; + }); + + // Project Series 변환 + const projectSeriesConverted = series.map((seriesItem) => { + return convertXMLToDBData<ProjectSeriesData>( + seriesItem as Record<string, string | undefined>, + undefined + ); + }); + + return { + biddingProjects: biddingProjectsConverted, + projectSeries: projectSeriesConverted, + }; } // 데이터베이스 저장 함수 -async function saveToDatabase(data: RequestData) { - // 트랜잭션으로 처리 +async function saveToDatabase(processedData: ProcessedBiddingData) { + console.log(`데이터베이스(배치) 저장 시작: ${processedData.biddingProjects.length}개 프로젝트, ${processedData.projectSeries.length}개 시리즈`); + try { - // bidding projects 처리 - for (const project of data.biddingProjects) { - // 기존 프로젝트 확인 - const existingProject = await db.query.biddingProjects.findFirst({ - where: eq(biddingProjects.pspid, project.pspid) - }); - - // shipMHull 값을 기준으로 pjtType 결정 - let pjtType: 'SHIP' | 'HULL' | null = null; - if (project.shipMHull) { - // shipMHull 값이 존재하면 해양(HULL) - pjtType = 'HULL'; - } else { - // shipMHull 값이 없으면 조선(SHIP) - pjtType = 'SHIP'; - } + await db.transaction(async (tx) => { + // 1) Bidding Projects UPSERT (배치) + const biddingProjectRows = processedData.biddingProjects.filter((p): p is BiddingProjectData => !!p.pspid); + await bulkUpsert(tx, biddingProjects, biddingProjectRows, 'pspid'); - const projectValues: NewBiddingProject = { - pspid: project.pspid, - projNm: project.projNm, - sector: project.sector, - projMsrm: project.projMsrm, - kunnr: project.kunnr, - kunnrNm: project.kunnrNm, - cls1: project.cls1, - cls1Nm: project.cls1Nm, - ptype: project.ptype, - ptypeNm: project.ptypeNm, - pmodelCd: project.pmodelCd, - pmodelNm: project.pmodelNm, - pmodelSz: project.pmodelSz, - pmodelUom: project.pmodelUom, - txt04: project.txt04, - txt30: project.txt30, - estmPm: project.estmPm, - pjtType: pjtType - }; - - if (existingProject) { - // 업데이트 - await db - .update(biddingProjects) - .set({ - ...projectValues, - updatedAt: new Date() - }) - .where(eq(biddingProjects.pspid, project.pspid)); - } else { - // 신규 등록 - await db.insert(biddingProjects).values(projectValues); - } - } - - // project series 처리 - for (const series of data.projectSeries) { - // 기존 시리즈 확인 - const existingSeries = await db.query.projectSeries.findFirst({ - where: and( - eq(projectSeries.pspid, series.pspid), - eq(projectSeries.sersNo, series.sersNo) - ) - }); - - const seriesValues: NewProjectSeries = { - pspid: series.pspid, - sersNo: series.sersNo, - scDt: series.scDt, - klDt: series.klDt, - lcDt: series.lcDt, - dlDt: series.dlDt, - dockNo: series.dockNo, - dockNm: series.dockNm - }; - - if (existingSeries) { - // 업데이트 - await db - .update(projectSeries) - .set(seriesValues) - .where(and( - eq(projectSeries.pspid, series.pspid), - eq(projectSeries.sersNo, series.sersNo) - )); - } else { - // 신규 등록 - await db.insert(projectSeries).values(seriesValues); - } - } - } catch (error: unknown) { - console.error('Database operation failed:', error); - throw new Error(`Database operation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + // 2) Project Series 교체 (배치) + const projectKeys = biddingProjectRows.map((p) => p.pspid as string); + await bulkReplaceSubTableData(tx, projectSeries, processedData.projectSeries, projectSeries.pspid, projectKeys); + }); + + console.log(`데이터베이스(배치) 저장 완료: ${processedData.biddingProjects.length}개 프로젝트, ${processedData.projectSeries.length}개 시리즈`); + return true; + } catch (error) { + console.error('데이터베이스(배치) 저장 중 오류 발생:', error); + throw error; } }
\ No newline at end of file diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts index 31b61ffc..fdf0c8d4 100644 --- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts +++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts @@ -1,6 +1,9 @@ import { NextRequest } from 'next/server'; import db from '@/db/db'; - +import { + PR_INFORMATION_T_BID_HEADER, + PR_INFORMATION_T_BID_ITEM, +} from '@/db/schema/ECC/ecc'; import { ToXMLFields, serveWsdl, @@ -11,14 +14,13 @@ import { createErrorResponse, createSuccessResponse, createSoapResponse, - replaceSubTableData, withSoapLogging, } from '@/lib/soap/utils'; - import { - PR_INFORMATION_T_BID_HEADER, - PR_INFORMATION_T_BID_ITEM, -} from '@/db/schema/ECC/ecc'; + bulkUpsert, + bulkReplaceSubTableData +} from "@/lib/soap/batch-utils"; + // 스키마에서 타입 추론 type BidHeaderData = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert; @@ -34,6 +36,7 @@ interface ProcessedPRData { bidItems: BidItemData[]; } +// GET 요청 처리는 ?wsdl 달고 있으면 WSDL 서비스 제공 export async function GET(request: NextRequest) { const url = new URL(request.url); if (url.searchParams.has('wsdl')) { @@ -43,6 +46,7 @@ export async function GET(request: NextRequest) { return new Response('Method Not Allowed', { status: 405 }); } +// POST 요청이 데이터 적재 요구 (SOAP) export async function POST(request: NextRequest) { const url = new URL(request.url); if (url.searchParams.has('wsdl')) { @@ -54,7 +58,7 @@ export async function POST(request: NextRequest) { // SOAP 로깅 래퍼 함수 사용 return withSoapLogging( 'INBOUND', - 'S-ERP', + 'ECC', 'IF_ECC_EVCP_PR_INFORMATION', body, async () => { @@ -155,45 +159,33 @@ function transformPRData(requestData: PRRequestXML): ProcessedPRData[] { // 데이터베이스 저장 함수 async function saveToDatabase(processedPRs: ProcessedPRData[]) { - console.log(`데이터베이스 저장 시작: ${processedPRs.length}개 PR 데이터`); + console.log(`데이터베이스(배치) 저장 시작: ${processedPRs.length}개 PR 데이터`); try { await db.transaction(async (tx) => { - for (const prData of processedPRs) { - const { bidHeader, bidItems } = prData; + // 1) 부모 테이블 데이터 준비 (키 없는 이상데이터 제거) + const bidHeaderRows = processedPRs + .map((c) => c.bidHeader) + .filter((h): h is BidHeaderData => !!h.ANFNR); - if (!bidHeader.ANFNR) { - console.warn('ANFNR가 없는 헤더 발견, 건너뜁니다.'); - continue; - } + const bidHeaderKeys = bidHeaderRows.map((h) => h.ANFNR as string); - // 1. 헤더 테이블 Upsert (ANFNR 기준) - await tx - .insert(PR_INFORMATION_T_BID_HEADER) - .values(bidHeader) - .onConflictDoUpdate({ - target: PR_INFORMATION_T_BID_HEADER.ANFNR, - set: { - ...bidHeader, - updatedAt: new Date(), - }, - }); - - // 2. 아이템 테이블 전체 교체 (ANFNR FK 기준) - await replaceSubTableData( - tx, - PR_INFORMATION_T_BID_ITEM, - bidItems, - 'ANFNR', - bidHeader.ANFNR - ); - } + // 2) 하위 테이블 데이터 평탄화 + const bidItems = processedPRs.flatMap((c) => c.bidItems); + + // 3) 부모 테이블 UPSERT (배치) + await bulkUpsert(tx, PR_INFORMATION_T_BID_HEADER, bidHeaderRows, 'ANFNR'); + + // 4) 하위 테이블 교체 (배치) + await Promise.all([ + bulkReplaceSubTableData(tx, PR_INFORMATION_T_BID_ITEM, bidItems, PR_INFORMATION_T_BID_ITEM.ANFNR, bidHeaderKeys), + ]); }); - console.log(`데이터베이스 저장 완료: ${processedPRs.length}개 PR`); + console.log(`데이터베이스(배치) 저장 완료: ${processedPRs.length}개 PR`); return true; } catch (error) { - console.error('데이터베이스 저장 중 오류 발생:', error); + console.error('데이터베이스(배치) 저장 중 오류 발생:', error); throw error; } }
\ No newline at end of file diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_CUSTOMER_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_CUSTOMER_MASTER/route.ts index 0cedcade..5b5bb1a8 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_CUSTOMER_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_CUSTOMER_MASTER/route.ts @@ -125,7 +125,7 @@ export async function POST(request: NextRequest) { // SOAP 로깅 래퍼 함수 사용 return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_CUSTOMER_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_DEPARTMENT_CODE/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_DEPARTMENT_CODE/route.ts index fb54dff3..0aedbf02 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_DEPARTMENT_CODE/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_DEPARTMENT_CODE/route.ts @@ -67,7 +67,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_DEPARTMENT_CODE', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_MASTER/route.ts index 388c4dc4..ee664d48 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_MASTER/route.ts @@ -154,7 +154,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_EMPLOYEE_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER/route.ts index 563696d3..ec1dad0c 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER/route.ts @@ -58,7 +58,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EQUP_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EQUP_MASTER/route.ts index 5544dfdb..9dcc4f82 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EQUP_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EQUP_MASTER/route.ts @@ -77,7 +77,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_EQUP_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts index 97c5f1c1..2c2ab0fc 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts @@ -77,7 +77,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_MATERIAL_MASTER_PART', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN/route.ts index 7b1b85e8..71479698 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN/route.ts @@ -46,7 +46,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MODEL_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MODEL_MASTER/route.ts index 9d76adbb..f0915527 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MODEL_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MODEL_MASTER/route.ts @@ -76,7 +76,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG 'IF_MDZ_EVCP_MODEL_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_ORGANIZATION_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_ORGANIZATION_MASTER/route.ts index 3051fd8f..4bd6a02c 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_ORGANIZATION_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_ORGANIZATION_MASTER/route.ts @@ -122,7 +122,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_ORGANIZATION_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_PROJECT_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_PROJECT_MASTER/route.ts index c1563859..fd7fb027 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_PROJECT_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_PROJECT_MASTER/route.ts @@ -47,7 +47,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_PROJECT_MASTER', body, async () => { diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_VENDOR_MASTER/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_VENDOR_MASTER/route.ts index 61269937..9c74b5c5 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_VENDOR_MASTER/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_VENDOR_MASTER/route.ts @@ -122,7 +122,7 @@ export async function POST(request: NextRequest) { return withSoapLogging( 'INBOUND', - 'S-ERP', + 'MDG', 'IF_MDZ_EVCP_VENDOR_MASTER', body, async () => { diff --git a/db/schema/ECC/ecc.ts b/db/schema/ECC/ecc.ts index e836909e..a1dfe15b 100644 --- a/db/schema/ECC/ecc.ts +++ b/db/schema/ECC/ecc.ts @@ -19,7 +19,7 @@ export const PR_INFORMATION_T_BID_HEADER = ECCSchema.table( 'PR_INFORMATION_T_BID_HEADER', { id: integer('id').primaryKey().generatedByDefaultAsIdentity(), - ANFNR: varchar({ length: 10 }).notNull(), // Bidding/RFQ Number // From: IF_ECC_EVCP_PR_INFORMATION.wsdl // Required + ANFNR: varchar({ length: 10 }).notNull().unique(), // Bidding/RFQ Number // From: IF_ECC_EVCP_PR_INFORMATION.wsdl // Required EKGRP: varchar({ length: 10 }), // Purchaing Group // From: IF_ECC_EVCP_PR_INFORMATION.wsdl EKORG: varchar({ length: 10 }), // Purchasing Organization // From: IF_ECC_EVCP_PR_INFORMATION.wsdl WERKS: varchar({ length: 10 }), // Plant // From: IF_ECC_EVCP_PR_INFORMATION.wsdl diff --git a/db/schema/procurementRFQ.ts b/db/schema/procurementRFQ.ts index 23521aaf..ef1198bc 100644 --- a/db/schema/procurementRFQ.ts +++ b/db/schema/procurementRFQ.ts @@ -61,6 +61,12 @@ export const procurementRfqs = pgTable( ); +/** + * 지불조건, 인코텀즈, 선적/하역(출발지, 도착지) 테이블은 Non-SAP에서 동기화 (Oracle DB to PostgreSQL) + * 동기화 로직은 instrumentation.ts 에서 node-cron 을 통해 job으로 등록됨 + */ + +// 지불조건 테이블 (Non-SAP 에서 동기화) export const paymentTerms = pgTable("payment_terms", { code: varchar("code", { length: 50 }).primaryKey(), description: varchar("description", { length: 255 }).notNull(), @@ -71,7 +77,7 @@ export const paymentTerms = pgTable("payment_terms", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -// 인코텀즈 테이블 +// 인코텀즈 테이블 (Non-SAP 에서 동기화) export const incoterms = pgTable("incoterms", { code: varchar("code", { length: 20 }).primaryKey(), description: varchar("description", { length: 255 }).notNull(), @@ -81,6 +87,16 @@ export const incoterms = pgTable("incoterms", { createdAt: timestamp("created_at").defaultNow().notNull(), }); +// 선적/하역 테이블 (Non-SAP 에서 동기화) +export const placeOfShipping = pgTable("place_of_shipping", { + code: varchar("code", { length: 20 }).primaryKey(), + description: varchar("description", { length: 255 }).notNull(), + isActive: boolean("is_active").default(true).notNull(), + createdBy: integer("created_by") + .references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + export const procurementRfqDetails = pgTable( "procurement_rfq_details", { diff --git a/instrumentation.ts b/instrumentation.ts index 97aa6143..db8da371 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -28,5 +28,17 @@ export async function register() { console.error('Failed to start Knox master sync scheduler.'); // 스케줄러 실패해도 애플리케이션은 계속 실행 } + + try { + // Procurement 동기화 스케줄러 시작 (지불조건, 인코텀즈, 선적/하역지) + const { startProcurementSyncScheduler } = await import( + './lib/nonsap-sync/procurement-sync-service' + ); + await startProcurementSyncScheduler(); + + } catch { + console.error('Failed to start Procurement sync scheduler.'); + // 스케줄러 실패해도 애플리케이션은 계속 실행 + } } } diff --git a/lib/nonsap-sync/procurement-sync-service.ts b/lib/nonsap-sync/procurement-sync-service.ts new file mode 100644 index 00000000..1f719526 --- /dev/null +++ b/lib/nonsap-sync/procurement-sync-service.ts @@ -0,0 +1,327 @@ +"use server"; + +import * as cron from 'node-cron'; +import { oracleKnex } from '@/lib/oracle-db/db'; +import db from '@/db/db'; +import { paymentTerms, incoterms, placeOfShipping } from '@/db/schema/procurementRFQ'; +import { inArray } from 'drizzle-orm'; + +// 간단한 로거 +const logger = { + info: (message: string, ...args: unknown[]) => console.log(`[PROCUREMENT-SYNC] ${message}`, ...args), + error: (message: string, ...args: unknown[]) => console.error(`[PROCUREMENT-SYNC ERROR] ${message}`, ...args), + warn: (message: string, ...args: unknown[]) => console.warn(`[PROCUREMENT-SYNC WARN] ${message}`, ...args), + success: (message: string, ...args: unknown[]) => console.log(`[PROCUREMENT-SYNC SUCCESS] ${message}`, ...args), + header: (message: string) => { + console.log('\n' + '='.repeat(80)); + console.log(`[PROCUREMENT-SYNC] ${message}`); + console.log('='.repeat(80) + '\n'); + } +}; + +/** + * Oracle DB 연결 테스트 + */ +async function testOracleConnection(): Promise<boolean> { + try { + const result = await oracleKnex.raw('SELECT 1 FROM DUAL'); + return result.rows && result.rows.length > 0; + } catch (error) { + logger.error('Oracle DB 연결 테스트 실패:', error); + return false; + } +} + +/** + * 지불조건 동기화 + */ +async function syncPaymentTerms(): Promise<void> { + logger.header('지불조건 동기화 시작'); + + try { + // Oracle에서 지불조건 데이터 조회 + const oracleData = await oracleKnex.raw(` + SELECT stc.CD_1 as code, stc.DSC as description + FROM SRMPTB_TYPE_CODE stc + WHERE stc.cd = 'PAYT' + `); + + const paymentTermsData = oracleData.rows || []; + logger.info(`Oracle에서 ${paymentTermsData.length}개의 지불조건 데이터 조회`); + + if (paymentTermsData.length === 0) { + logger.warn('Oracle에서 지불조건 데이터가 없습니다'); + return; + } + + // PostgreSQL에서 기존 데이터 조회 + const existingData = await db.select().from(paymentTerms); + const existingCodes = new Set(existingData.map(item => item.code)); + + // Oracle에서 조회한 코드들 + const oracleCodes = new Set(paymentTermsData.map((item: { CODE: string; DESCRIPTION: string }) => item.CODE)); + + // 업데이트할 데이터 준비 + const upsertData = paymentTermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({ + code: item.CODE, + description: item.DESCRIPTION, + isActive: true // 기본값 true + })); + + // PostgreSQL에 upsert + for (const data of upsertData) { + await db.insert(paymentTerms) + .values(data) + .onConflictDoUpdate({ + target: paymentTerms.code, + set: { + description: data.description, + isActive: data.isActive + } + }); + } + + // PostgreSQL에만 있는 데이터는 isActive = false로 설정 + const codesToDeactivate = new Set([...existingCodes].filter(code => !oracleCodes.has(code))); + if (codesToDeactivate.size > 0) { + await db.update(paymentTerms) + .set({ isActive: false }) + .where(inArray(paymentTerms.code, Array.from(codesToDeactivate))); + + logger.info(`${codesToDeactivate.size}개의 지불조건을 비활성화했습니다`); + } + + logger.success(`지불조건 동기화 완료: ${upsertData.length}개 업데이트`); + } catch (error) { + logger.error('지불조건 동기화 중 오류:', error); + throw error; + } +} + +/** + * 인코텀즈 동기화 + */ +async function syncIncoterms(): Promise<void> { + logger.header('인코텀즈 동기화 시작'); + + try { + // Oracle에서 인코텀즈 데이터 조회 + const oracleData = await oracleKnex.raw(` + SELECT stc.CD_1 as code, stc.DSC as description + FROM SRMPTB_TYPE_CODE stc + WHERE stc.cd = 'INCO' + `); + + const incotermsData = oracleData.rows || []; + logger.info(`Oracle에서 ${incotermsData.length}개의 인코텀즈 데이터 조회`); + + if (incotermsData.length === 0) { + logger.warn('Oracle에서 인코텀즈 데이터가 없습니다'); + return; + } + + // PostgreSQL에서 기존 데이터 조회 + const existingData = await db.select().from(incoterms); + const existingCodes = new Set(existingData.map(item => item.code)); + + // Oracle에서 조회한 코드들 + const oracleCodes = new Set(incotermsData.map((item: { CODE: string; DESCRIPTION: string }) => item.CODE)); + + // 업데이트할 데이터 준비 + const upsertData = incotermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({ + code: item.CODE, + description: item.DESCRIPTION, + isActive: true // 기본값 true + })); + + // PostgreSQL에 upsert + for (const data of upsertData) { + await db.insert(incoterms) + .values(data) + .onConflictDoUpdate({ + target: incoterms.code, + set: { + description: data.description, + isActive: data.isActive + } + }); + } + + // PostgreSQL에만 있는 데이터는 isActive = false로 설정 + const codesToDeactivate = new Set([...existingCodes].filter(code => !oracleCodes.has(code))); + if (codesToDeactivate.size > 0) { + await db.update(incoterms) + .set({ isActive: false }) + .where(inArray(incoterms.code, Array.from(codesToDeactivate))); + + logger.info(`${codesToDeactivate.size}개의 인코텀즈를 비활성화했습니다`); + } + + logger.success(`인코텀즈 동기화 완료: ${upsertData.length}개 업데이트`); + } catch (error) { + logger.error('인코텀즈 동기화 중 오류:', error); + throw error; + } +} + +/** + * 선적/하역지 동기화 + */ +async function syncPlaceOfShipping(): Promise<void> { + logger.header('선적/하역지 동기화 시작'); + + try { + // Oracle에서 선적/하역지 데이터 조회 + const oracleData = await oracleKnex.raw(` + SELECT cd.CD as code, cdnm.CDNM as description, cd.DEL_YN as isActive + FROM CMCTB_CD cd + INNER JOIN CMCTB_CDNM cdnm ON cdnm.cd = cd.cd + AND CDNM.CD_CLF = CD.CD_CLF + WHERE cd.CD_CLF = 'MMM050' + ORDER BY cd.CD asc, cdnm.CDNM asc + `); + + const placeOfShippingData = oracleData.rows || []; + logger.info(`Oracle에서 ${placeOfShippingData.length}개의 선적/하역지 데이터 조회`); + + if (placeOfShippingData.length === 0) { + logger.warn('Oracle에서 선적/하역지 데이터가 없습니다'); + return; + } + + // PostgreSQL에서 기존 데이터 조회 + const existingData = await db.select().from(placeOfShipping); + const existingCodes = new Set(existingData.map(item => item.code)); + + // Oracle에서 조회한 코드들 + const oracleCodes = new Set(placeOfShippingData.map((item: { CODE: string; DESCRIPTION: string; ISACTIVE: string }) => item.CODE)); + + // 업데이트할 데이터 준비 (isActive = "Y"인 경우 true, 그 외는 기본값 true) + const upsertData = placeOfShippingData.map((item: { CODE: string; DESCRIPTION: string; ISACTIVE: string }) => ({ + code: item.CODE, + description: item.DESCRIPTION, + isActive: item.ISACTIVE === 'Y' ? true : true // 기본값 true + })); + + // PostgreSQL에 upsert + for (const data of upsertData) { + await db.insert(placeOfShipping) + .values(data) + .onConflictDoUpdate({ + target: placeOfShipping.code, + set: { + description: data.description, + isActive: data.isActive + } + }); + } + + // PostgreSQL에만 있는 데이터는 isActive = false로 설정 + const codesToDeactivate = new Set([...existingCodes].filter(code => !oracleCodes.has(code))); + if (codesToDeactivate.size > 0) { + await db.update(placeOfShipping) + .set({ isActive: false }) + .where(inArray(placeOfShipping.code, Array.from(codesToDeactivate))); + + logger.info(`${codesToDeactivate.size}개의 선적/하역지를 비활성화했습니다`); + } + + logger.success(`선적/하역지 동기화 완료: ${upsertData.length}개 업데이트`); + } catch (error) { + logger.error('선적/하역지 동기화 중 오류:', error); + throw error; + } +} + +/** + * 모든 procurement 관련 테이블 동기화 + */ +export async function syncAllProcurementTables(): Promise<void> { + logger.header('Procurement 테이블 동기화 시작'); + + try { + await syncPaymentTerms(); + await syncIncoterms(); + await syncPlaceOfShipping(); + + logger.success('모든 Procurement 테이블 동기화 완료'); + } catch (error) { + logger.error('Procurement 테이블 동기화 중 오류:', error); + throw error; + } +} + +/** + * 수동 동기화 트리거 + */ +export async function triggerProcurementSync(): Promise<void> { + logger.info('수동 Procurement 동기화 시작'); + await syncAllProcurementTables(); + logger.success('수동 Procurement 동기화 완료'); +} + +/** + * Procurement 동기화 스케줄러 시작 + */ +export async function startProcurementSyncScheduler(): Promise<void> { + logger.info('Initializing Procurement data synchronization scheduler...'); + + // Oracle DB 연결 테스트 (비동기) + testOracleConnection().then(isConnected => { + if (!isConnected) { + logger.warn('Oracle DB connection failed - procurement sync scheduler will be disabled'); + logger.warn('Application will continue to run normally'); + return; + } + logger.success('Oracle DB connection test passed'); + }).catch(error => { + logger.error('Oracle DB connection test error:', error); + logger.warn('Procurement sync scheduler will be disabled, application continues'); + }); + + try { + // 매일 새벽 2시에 실행 (기존 스케줄러들과 시간 분산) + cron.schedule('0 2 * * *', async () => { + try { + logger.info('Cron job triggered: Starting scheduled procurement sync'); + + // 동기화 전 Oracle 연결 확인 + const isConnected = await testOracleConnection(); + if (!isConnected) { + logger.warn('Oracle DB not available, skipping procurement sync'); + return; + } + + await syncAllProcurementTables(); + } catch (error) { + logger.error('Scheduled procurement sync failed:', error); + // 동기화 실패해도 다음 스케줄은 계속 실행 + } + }, { + timezone: 'Asia/Seoul' + }); + + logger.success('Procurement data synchronization cron job registered (daily at 2:00 AM)'); + + // 애플리케이션 시작 시 한 번 실행 (선택사항) + if (process.env.PROCUREMENT_SYNC_ON_START === 'true') { + logger.info('Initial procurement sync on startup enabled'); + setTimeout(async () => { + try { + const isConnected = await testOracleConnection(); + if (isConnected) { + await syncAllProcurementTables(); + } else { + logger.warn('Initial procurement sync skipped - Oracle DB not available'); + } + } catch (error) { + logger.error('Initial procurement sync failed:', error); + } + }, 15000); // 15초 후 실행 (다른 스케줄러들과 시간 분산) + } + + } catch (error) { + logger.error('Failed to set up procurement cron scheduler:', error); + logger.warn('Application will continue without procurement sync scheduler'); + } +}
\ No newline at end of file |
