summaryrefslogtreecommitdiff
path: root/lib/soap/mdg
diff options
context:
space:
mode:
Diffstat (limited to 'lib/soap/mdg')
-rw-r--r--lib/soap/mdg/send/vendor-master/action.ts584
-rw-r--r--lib/soap/mdg/utils.ts450
2 files changed, 1034 insertions, 0 deletions
diff --git a/lib/soap/mdg/send/vendor-master/action.ts b/lib/soap/mdg/send/vendor-master/action.ts
new file mode 100644
index 00000000..34ce242c
--- /dev/null
+++ b/lib/soap/mdg/send/vendor-master/action.ts
@@ -0,0 +1,584 @@
+'use server'
+
+import db from "@/db/db";
+import {
+ VENDOR_MASTER_BP_HEADER,
+ VENDOR_MASTER_BP_HEADER_ADDRESS,
+ VENDOR_MASTER_BP_HEADER_ADDRESS_AD_EMAIL,
+ VENDOR_MASTER_BP_HEADER_ADDRESS_AD_FAX,
+ VENDOR_MASTER_BP_HEADER_ADDRESS_AD_POSTAL,
+ VENDOR_MASTER_BP_HEADER_ADDRESS_AD_TEL,
+ VENDOR_MASTER_BP_HEADER_ADDRESS_AD_URL,
+ VENDOR_MASTER_BP_HEADER_BP_TAXNUM,
+ VENDOR_MASTER_BP_HEADER_BP_VENGEN,
+ VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY,
+ VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY_BP_WHTAX,
+ VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG,
+ VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN
+} from "@/db/schema/MDG/mdg";
+import { eq, sql, desc } from "drizzle-orm";
+import { withSoapLogging } from "../../utils";
+import fs from 'fs';
+import path from 'path';
+
+// WSDL 엔드포인트 URL (WSDL에서 추출)
+const MDG_ENDPOINT_URL = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_D&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MD%2FMDZ%5EP2MD3007_AO&QualityOfService=ExactlyOnce";
+
+// CSV 파싱 및 필드 정의 ------------------------------------------------
+interface CsvField {
+ table: string;
+ field: string;
+ mandatory: boolean;
+}
+
+function parseCsv(content: string): CsvField[] {
+ const lines = content.trim().split('\n');
+ return lines.slice(1).map(line => {
+ const parts = line.split(',');
+ return {
+ table: parts[1]?.trim(),
+ field: parts[2]?.trim(),
+ mandatory: parts[3]?.trim() === 'M'
+ } as CsvField;
+ });
+}
+
+// 모듈 초기화 시 CSV 로드
+const CSV_PATH = path.join(process.cwd(), 'public', 'wsdl', 'P2MD3007_AO.csv');
+let CSV_FIELDS: CsvField[] = [];
+try {
+ const csvRaw = fs.readFileSync(CSV_PATH, 'utf-8');
+ CSV_FIELDS = parseCsv(csvRaw);
+} catch (e) {
+ console.error('CSV 로딩 실패:', e);
+}
+
+// XML escape helper
+const escapeXml = (unsafe: string) => unsafe.replace(/[<>&'"']/g, (c) => {
+ switch (c) {
+ case '<': return '&lt;';
+ case '>': return '&gt;';
+ case '&': return '&amp;';
+ case '"': return '&quot;';
+ case "'": return '&apos;';
+ default: return c;
+ }
+});
+
+// VENDOR 마스터 데이터를 MDG로 송신하는 액션
+export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ vendorCode: string; success: boolean; error?: string }>;
+}> {
+ try {
+ console.log(`🚀 VENDOR_MASTER 송신 시작: ${vendorCodes.length}개 벤더`);
+
+ const results: Array<{ vendorCode: string; success: boolean; error?: string }> = [];
+
+ // 각 VENDOR 코드별로 개별 전송 (MDG 시스템의 처리 제한 고려)
+ for (const vendorCode of vendorCodes) {
+ try {
+ console.log(`📤 VENDOR ${vendorCode} 데이터 조회 중...`);
+
+ // 데이터베이스에서 VENDOR 데이터 조회
+ const vendorData = await fetchVendorData(vendorCode);
+
+ if (!vendorData) {
+ results.push({
+ vendorCode,
+ success: false,
+ error: 'VENDOR 데이터를 찾을 수 없습니다.'
+ });
+ continue;
+ }
+
+ // XML 생성
+ const soapXml = buildSoapXML(vendorData);
+ console.log(`📄 VENDOR ${vendorCode} XML 생성 완료`);
+
+ // SOAP 요청 전송
+ await withSoapLogging(
+ 'OUTBOUND',
+ 'MDG',
+ 'IF_MDZ_EVCP_VENDOR_MASTER',
+ soapXml,
+ async () => {
+ const response = await fetch(MDG_ENDPOINT_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/xml; charset=utf-8',
+ 'SOAPAction': 'http://sap.com/xi/WebService/soap1.1',
+ },
+ body: soapXml,
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const responseText = await response.text();
+ console.log(`✅ VENDOR ${vendorCode} MDG 전송 성공`);
+
+ // SOAP Fault 체크
+ if (responseText.includes('soap:Fault') || responseText.includes('SOAP:Fault')) {
+ throw new Error(`MDG SOAP Fault: ${responseText}`);
+ }
+
+ return responseText;
+ }
+ );
+
+ results.push({
+ vendorCode,
+ success: true
+ });
+
+ } catch (error) {
+ console.error(`❌ VENDOR ${vendorCode} 전송 실패:`, error);
+ results.push({
+ vendorCode,
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ console.log(`🎉 VENDOR_MASTER 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: failCount === 0,
+ message: `전송 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ VENDOR_MASTER 송신 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 데이터베이스에서 VENDOR 데이터 조회
+async function fetchVendorData(vendorCode: string) {
+ try {
+ // 1. 헤더 데이터 조회
+ const [vendorHeader] = await db
+ .select()
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(eq(VENDOR_MASTER_BP_HEADER.VNDRCD, vendorCode))
+ .limit(1);
+
+ if (!vendorHeader) {
+ return null;
+ }
+
+ // 2. 관련 데이터 병렬 조회
+ const [
+ addresses,
+ adEmails,
+ adFaxes,
+ adPostals,
+ adTels,
+ adUrls,
+ bpTaxnums,
+ bpVengens,
+ bpCompnies,
+ bpWhtaxes,
+ bpPorgs,
+ zvpfns
+ ] = await Promise.all([
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_EMAIL).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_EMAIL.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_FAX).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_FAX.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_POSTAL).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_POSTAL.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_TEL).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_TEL.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_URL).where(eq(VENDOR_MASTER_BP_HEADER_ADDRESS_AD_URL.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_TAXNUM).where(eq(VENDOR_MASTER_BP_HEADER_BP_TAXNUM.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY_BP_WHTAX).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_COMPNY_BP_WHTAX.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG.VNDRCD, vendorCode)),
+ db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN.VNDRCD, vendorCode))
+ ]);
+
+ return {
+ vendorHeader,
+ addresses,
+ adEmails,
+ adFaxes,
+ adPostals,
+ adTels,
+ adUrls,
+ bpTaxnums,
+ bpVengens,
+ bpCompnies,
+ bpWhtaxes,
+ bpPorgs,
+ zvpfns
+ };
+
+ } catch (error) {
+ console.error(`VENDOR ${vendorCode} 데이터 조회 실패:`, error);
+ throw error;
+ }
+}
+
+// SOAP XML 생성 (WSDL 구조에 맞춤)
+function buildSoapXML(vendorData: NonNullable<Awaited<ReturnType<typeof fetchVendorData>>>): string {
+ const { vendorHeader, addresses, adEmails, adFaxes, adPostals, adTels, adUrls, bpTaxnums, bpVengens } = vendorData;
+
+ // 값 추출 매핑 ------------------------------------
+ const mapping: Record<string, string | undefined> = {
+ // Header
+ BP_HEADER: vendorHeader?.VNDRCD,
+ ZZSRMCD: 'EVCP',
+ TITLE: vendorHeader?.TITLE ?? '',
+ BU_SORT1: adPostals[0]?.VNDRNM_ABRV_1,
+ NAME_ORG1: adPostals[0]?.VNDRNM_1,
+ KTOKK: bpVengens[0]?.ACNT_GRP,
+ MASTERFLAG: 'X',
+ IBND_TYPE: 'U',
+ // Address mandatory (first)
+ ADDRNO: addresses[0]?.ADDRNO,
+ AD_NATION: adPostals[0]?.INTL_ADR_VER_ID,
+ COUNTRY: adPostals[0]?.NTN_CD,
+ LANGU_COM: adPostals[0]?.LANG_KEY,
+ POST_COD1: adPostals[0]?.CITY_ZIP_NO,
+ CITY1: adPostals[0]?.VNDRNM_1,
+ MC_STREET: adPostals[0]?.ADR_1,
+ // Phone/Fax mandatory fields
+ AD_CONSNO: '001',
+ T_COUNTRY: adTels[0]?.CTRY_CD ?? 'KR',
+ F_COUNTRY: adFaxes[0]?.CTRY_CD ?? 'KR',
+ // Tax
+ BP_TX_TYP: bpTaxnums[0]?.TX_NO_CTG ?? 'KR2',
+ TAXNUM: bpVengens[0]?.VAT_REG_NO,
+ // Default others can be added as needed
+ };
+
+ // 필드 순서에 따라 XML 생성
+ const seen = new Set<string>();
+ const uniqueFields = CSV_FIELDS.filter(f => {
+ if (seen.has(f.field)) return false;
+ seen.add(f.field);
+ return true;
+ });
+
+ const fieldXml = uniqueFields.map(f => {
+ const val = mapping[f.field] ?? '';
+ return `<${f.field}>${escapeXml(val ?? '')}</${f.field}>`;
+ }).join('\n ');
+
+ const supplierMasterXml = `<SUPPLIER_MASTER>\n ${fieldXml}\n </SUPPLIER_MASTER>`;
+
+ const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>\n<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:p1="http://shi.samsung.co.kr/P2_MD/MDZ">\n <soap:Header/>\n <soap:Body>\n <p1:MT_P2MD3007_S>\n <P2MD3007_S>\n ${supplierMasterXml}\n </P2MD3007_S>\n </p1:MT_P2MD3007_S>\n </soap:Body>\n</soap:Envelope>`;
+
+ return soapEnvelope.trim();
+}
+
+// 특정 VENDOR만 송신하는 유틸리티 함수
+export async function sendSingleVendorToMDG(vendorCode: string) {
+ return await sendVendorMasterToMDG([vendorCode]);
+}
+
+// 모든 VENDOR 송신하는 유틸리티 함수 (주의: 대량 데이터 처리)
+export async function sendAllVendorsToMDG() {
+ try {
+ // 모든 VENDOR 코드 조회
+ const vendors = await db
+ .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD })
+ .from(VENDOR_MASTER_BP_HEADER);
+
+ const vendorCodes = vendors.map(v => v.VNDRCD);
+
+ if (vendorCodes.length === 0) {
+ return {
+ success: false,
+ message: '송신할 VENDOR 데이터가 없습니다.'
+ };
+ }
+
+ console.log(`⚠️ 전체 VENDOR 송신 요청: ${vendorCodes.length}개`);
+
+ // 배치 처리 (10개씩 분할하여 처리)
+ const batchSize = 10;
+ const results: Array<{ vendorCode: string; success: boolean; error?: string }> = [];
+
+ for (let i = 0; i < vendorCodes.length; i += batchSize) {
+ const batch = vendorCodes.slice(i, i + batchSize);
+ console.log(`📦 배치 ${Math.floor(i / batchSize) + 1} 처리 중... (${batch.length}개)`);
+
+ const batchResult = await sendVendorMasterToMDG(batch);
+ if (batchResult.results) {
+ results.push(...batchResult.results);
+ }
+
+ // 배치 간 잠깐 대기 (서버 부하 방지)
+ if (i + batchSize < vendorCodes.length) {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ return {
+ success: failCount === 0,
+ message: `전체 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('전체 VENDOR 송신 중 오류:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 수정된 VENDOR만 송신하는 액션 (updatedAt != createdAt 조건)
+export async function sendModifiedVendorsToMDG(): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ vendorCode: string; success: boolean; error?: string }>;
+}> {
+ try {
+ console.log('🔍 수정된 VENDOR 데이터 조회 중...');
+
+ // updatedAt과 createdAt이 다른 VENDOR 조회 (수정된 것들)
+ const modifiedVendors = await db
+ .select({
+ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD,
+ createdAt: VENDOR_MASTER_BP_HEADER.createdAt,
+ updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt
+ })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(
+ // PostgreSQL에서 timestamp 비교 (밀리초 차이 고려)
+ sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1`
+ );
+
+ const vendorCodes = modifiedVendors.map(v => v.VNDRCD);
+
+ if (vendorCodes.length === 0) {
+ console.log('📝 수정된 VENDOR 데이터가 없습니다.');
+ return {
+ success: true,
+ message: '수정된 VENDOR 데이터가 없습니다.'
+ };
+ }
+
+ console.log(`📋 수정된 VENDOR ${vendorCodes.length}개 발견:`, vendorCodes);
+
+ // 수정된 VENDOR들의 수정 시간 로그
+ modifiedVendors.forEach(vendor => {
+ console.log(` - ${vendor.VNDRCD}: 생성 ${vendor.createdAt?.toISOString()}, 수정 ${vendor.updatedAt?.toISOString()}`);
+ });
+
+ // 배치 처리로 송신
+ const batchSize = 10;
+ const results: Array<{ vendorCode: string; success: boolean; error?: string }> = [];
+
+ for (let i = 0; i < vendorCodes.length; i += batchSize) {
+ const batch = vendorCodes.slice(i, i + batchSize);
+ console.log(`📦 수정 데이터 배치 ${Math.floor(i / batchSize) + 1} 처리 중... (${batch.length}개)`);
+
+ const batchResult = await sendVendorMasterToMDG(batch);
+ if (batchResult.results) {
+ results.push(...batchResult.results);
+ }
+
+ // 배치 간 대기
+ if (i + batchSize < vendorCodes.length) {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ console.log(`🎯 수정된 VENDOR 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: failCount === 0,
+ message: `수정된 VENDOR 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 수정된 VENDOR 송신 중 오류:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 테스트용 N건 송신 액션
+export async function sendNVendorsToMDG(
+ count: number,
+ startFrom: number = 0
+): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ vendorCode: string; success: boolean; error?: string }>;
+}> {
+ try {
+ if (count <= 0) {
+ return {
+ success: false,
+ message: '송신할 건수는 1 이상이어야 합니다.'
+ };
+ }
+
+ console.log(`🧪 테스트용 VENDOR 송신: ${count}건 (${startFrom}번째부터)`);
+
+ // N건의 VENDOR 코드 조회
+ const vendors = await db
+ .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .limit(count)
+ .offset(startFrom);
+
+ const vendorCodes = vendors.map(v => v.VNDRCD);
+
+ if (vendorCodes.length === 0) {
+ return {
+ success: false,
+ message: `${startFrom}번째부터 ${count}건의 VENDOR 데이터가 없습니다.`
+ };
+ }
+
+ console.log(`📋 테스트 대상 VENDOR ${vendorCodes.length}개:`, vendorCodes);
+
+ // 송신 실행
+ const result = await sendVendorMasterToMDG(vendorCodes);
+
+ console.log(`🧪 테스트 송신 완료: ${vendorCodes.length}개 처리`);
+
+ return {
+ ...result,
+ message: `테스트 송신 완료 (${vendorCodes.length}개): ${result.message}`
+ };
+
+ } catch (error) {
+ console.error('❌ 테스트 송신 중 오류:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 최신 수정된 N건 송신 액션 (최근 수정 순)
+export async function sendRecentModifiedVendorsToMDG(
+ count: number = 5
+): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ vendorCode: string; success: boolean; error?: string }>;
+}> {
+ try {
+ console.log(`🕒 최근 수정된 VENDOR ${count}건 조회 중...`);
+
+ // 최근 수정된 순으로 N건 조회
+ const recentVendors = await db
+ .select({
+ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD,
+ updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt
+ })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(
+ // 수정된 항목만 (updatedAt != createdAt)
+ sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1`
+ )
+ .orderBy(desc(VENDOR_MASTER_BP_HEADER.updatedAt))
+ .limit(count);
+
+ const vendorCodes = recentVendors.map(v => v.VNDRCD);
+
+ if (vendorCodes.length === 0) {
+ return {
+ success: true,
+ message: '최근 수정된 VENDOR 데이터가 없습니다.'
+ };
+ }
+
+ console.log(`📋 최근 수정된 VENDOR ${vendorCodes.length}개:`,
+ recentVendors.map(v => `${v.VNDRCD}(${v.updatedAt?.toISOString()})`));
+
+ // 송신 실행
+ const result = await sendVendorMasterToMDG(vendorCodes);
+
+ console.log(`🕒 최근 수정 데이터 송신 완료`);
+
+ return {
+ ...result,
+ message: `최근 수정된 ${vendorCodes.length}개 송신 완료: ${result.message}`
+ };
+
+ } catch (error) {
+ console.error('❌ 최근 수정 데이터 송신 중 오류:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 통계 조회 유틸리티 함수
+export async function getVendorSendStatistics(): Promise<{
+ total: number;
+ modified: number;
+ lastModified?: Date;
+ oldestUnmodified?: Date;
+}> {
+ try {
+ const [totalResult] = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(VENDOR_MASTER_BP_HEADER);
+
+ const [modifiedResult] = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(
+ sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1`
+ );
+
+ const [lastModifiedResult] = await db
+ .select({ updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(
+ sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1`
+ )
+ .orderBy(desc(VENDOR_MASTER_BP_HEADER.updatedAt))
+ .limit(1);
+
+ const [oldestUnmodifiedResult] = await db
+ .select({ createdAt: VENDOR_MASTER_BP_HEADER.createdAt })
+ .from(VENDOR_MASTER_BP_HEADER)
+ .where(
+ sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) <= 1`
+ )
+ .orderBy(VENDOR_MASTER_BP_HEADER.createdAt)
+ .limit(1);
+
+ return {
+ total: totalResult.count,
+ modified: modifiedResult.count,
+ lastModified: lastModifiedResult?.updatedAt || undefined,
+ oldestUnmodified: oldestUnmodifiedResult?.createdAt || undefined
+ };
+
+ } catch (error) {
+ console.error('통계 조회 실패:', error);
+ throw error;
+ }
+}
diff --git a/lib/soap/mdg/utils.ts b/lib/soap/mdg/utils.ts
new file mode 100644
index 00000000..437988dc
--- /dev/null
+++ b/lib/soap/mdg/utils.ts
@@ -0,0 +1,450 @@
+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";
+
+// XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성
+export type ToXMLFields<T> = {
+ [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<string, unknown>,
+ 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<string, unknown> | 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<T extends Record<string, unknown>>(
+ xmlData: Record<string, string | undefined>,
+ fkData?: Record<string, string>
+): 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<string, unknown>)[key] = value || null;
+ }
+ }
+
+ // FK 필드 처리 (XML 우선, 없으면 상위에서 전달받은 값 사용)
+ if (fkData) {
+ for (const [key, value] of Object.entries(fkData)) {
+ // XML에 해당 FK 필드가 없거나 비어있는 경우에만 상위 값 사용
+ const existingValue = (result as Record<string, unknown>)[key];
+ if (!existingValue || existingValue === null || existingValue === '') {
+ (result as Record<string, unknown>)[key] = value;
+ }
+ // XML에 이미 FK 필드가 있고 값이 있는 경우는 XML 값을 그대로 사용
+ }
+ }
+
+ return result;
+}
+
+// 중첩 배열 처리 함수 (개선된 버전)
+/**
+ * 중첩된 배열 데이터를 처리하여 DB 삽입 가능한 형태로 변환
+ *
+ * 처리 방식:
+ * - 하위 테이블 데이터는 FK만 설정하면 됨
+ * - 별도의 필수 필드 생성 로직 불필요
+ * - 전체 데이터셋 기반으로 삭제 후 재삽입 처리
+ *
+ * @param items 처리할 배열 데이터
+ * @param converter 변환 함수
+ * @param fkData FK 데이터
+ * @returns 변환된 배열 데이터
+ */
+export function processNestedArray<T, U>(
+ items: T[] | undefined,
+ converter: (item: T, fkData?: Record<string, string>) => U,
+ fkData?: Record<string, string>
+): U[] {
+ if (!items || !Array.isArray(items)) {
+ return [];
+ }
+
+ return items.map(item => converter(item, fkData));
+}
+
+// 에러 응답 생성
+export function createErrorResponse(error: unknown): NextResponse {
+ console.error('API Error:', error);
+
+ 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',
+ },
+ });
+}
+
+// 성공 응답 생성
+export function createSuccessResponse(namespace: string): NextResponse {
+ const xmlResponse = `<?xml version="1.0" encoding="UTF-8"?>
+<soap:Envelope
+ xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
+ xmlns:tns="${namespace}">
+ <soap:Body>
+ </soap:Body>
+</soap:Envelope>`;
+
+ return new NextResponse(xmlResponse, {
+ 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<T extends Record<string, unknown>>(
+ tx: Parameters<Parameters<typeof db.transaction>[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<number> {
+ 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<void> {
+ 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<void> {
+ 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<T>(
+ direction: LogDirection,
+ system: string,
+ interfaceName: string,
+ requestData: string,
+ processor: () => Promise<T>
+): Promise<T> {
+ 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;
+ }
+} \ No newline at end of file