summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-08-04 11:07:10 +0000
committerjoonhoekim <26rote@gmail.com>2025-08-04 11:07:10 +0000
commit305a29e0e245ed9de1a7f4d32e96634116dcda82 (patch)
treebba0f654d7072ab8b619ac08ccc4b988fef143f3
parent4dc27e9495b005b29b4d7a2ad404c8c0644769eb (diff)
(김준회) SOAP ECC 개선, 린터 오류 해결, 구매 기준정보(인코텀즈, 지불조건, 장소(출발지, 도착지)) Non-SAP 동기화 처리
-rw-r--r--.env.development17
-rw-r--r--.env.production7
-rw-r--r--.gitignore3
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts481
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts66
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_CUSTOMER_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_DEPARTMENT_CODE/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EMPLOYEE_REFERENCE_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_EQUP_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART_RETURN/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MODEL_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_ORGANIZATION_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_PROJECT_MASTER/route.ts2
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_VENDOR_MASTER/route.ts2
-rw-r--r--db/schema/ECC/ecc.ts2
-rw-r--r--db/schema/procurementRFQ.ts18
-rw-r--r--instrumentation.ts12
-rw-r--r--lib/nonsap-sync/procurement-sync-service.ts327
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
diff --git a/.gitignore b/.gitignore
index 8ebd6c51..267662e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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