import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils'; import db from '@/db/db'; import { contracts, contractItems, } from '@/db/schema/contract'; import { projects } from '@/db/schema/projects'; import { vendors } from '@/db/schema/vendors'; import { items } from '@/db/schema/items'; import { ZMM_HD, ZMM_DT, } from '@/db/schema/ECC/ecc'; import { eq } from 'drizzle-orm'; // ECC 데이터 타입 정의 export type ECCPOHeader = typeof ZMM_HD.$inferInsert & { notes?: ECCPONote[]; }; export type ECCPODetail = typeof ZMM_DT.$inferInsert; export type ECCPONote = { ZNOTE_SER: string; ZNOTE_TXT: string; }; // 비즈니스 테이블 데이터 타입 정의 export type ContractData = typeof contracts.$inferInsert; export type ContractItemData = typeof contractItems.$inferInsert; // 처리된 PO 데이터 구조 export interface ProcessedPOData { header: ECCPOHeader; details: ECCPODetail[]; } /** * ECC PO 헤더 데이터를 contracts 테이블로 매핑 */ export async function mapECCPOHeaderToBusiness( eccHeader: ECCPOHeader ): Promise { debugLog('ECC PO 헤더 매핑 시작', { ebeln: eccHeader.EBELN }); // projectId 찾기 (PSPID → projects.code 기반) let projectId: number | null = null; if (eccHeader.PSPID) { try { const project = await db.query.projects.findFirst({ where: eq(projects.code, eccHeader.PSPID), }); if (project) { projectId = project.id; debugLog('프로젝트 ID 찾음', { pspid: eccHeader.PSPID, projectId }); } else { debugError('프로젝트를 찾을 수 없음', { pspid: eccHeader.PSPID }); } } catch (error) { debugError('프로젝트 조회 중 오류', { pspid: eccHeader.PSPID, error }); } } // vendorId 찾기 (LIFNR 기반) let vendorId: number | null = null; if (eccHeader.LIFNR) { try { const vendor = await db.query.vendors.findFirst({ where: eq(vendors.vendorCode, eccHeader.LIFNR), }); if (vendor) { vendorId = vendor.id; debugLog('벤더 ID 찾음', { lifnr: eccHeader.LIFNR, vendorId }); } else { debugError('벤더를 찾을 수 없음', { lifnr: eccHeader.LIFNR }); } } catch (error) { debugError('벤더 조회 중 오류', { lifnr: eccHeader.LIFNR, error }); } } // 날짜 파싱 함수 (YYYYMMDD -> YYYY-MM-DD 문자열 형식) const parseDate = (dateStr: string | null): string | null => { if (!dateStr || dateStr.length !== 8) return null; try { const year = dateStr.substring(0, 4); const month = dateStr.substring(4, 6); const day = dateStr.substring(6, 8); return `${year}-${month}-${day}`; } catch (error) { debugError('날짜 파싱 오류', { dateStr, error }); return null; } }; // 금액 파싱 함수 const parseAmount = (amountStr: string | null): string | null => { if (!amountStr) return null; try { // 문자열을 숫자로 변환 후 다시 문자열로 (소수점 처리) const num = parseFloat(amountStr); return isNaN(num) ? null : num.toString(); } catch (error) { debugError('금액 파싱 오류', { amountStr, error }); return null; } }; // projectId와 vendorId 필수 체크 if (!projectId) { debugError('프로젝트를 찾을 수 없어 매핑을 건너뜁니다', { pspid: eccHeader.PSPID }); throw new Error(`프로젝트를 찾을 수 없습니다: PSPID=${eccHeader.PSPID}`); } if (!vendorId) { debugError('벤더를 찾을 수 없어 매핑을 건너뜁니다', { lifnr: eccHeader.LIFNR }); throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}`); } // 계약서 내용 구성 (ZMM_NOTE에서 가져옴) let contractContent: string | null = null; if (eccHeader.notes && eccHeader.notes.length > 0) { // ZNOTE_SER 순번으로 정렬 후 텍스트 합치기 const sortedNotes = eccHeader.notes.sort((a, b) => parseInt(a.ZNOTE_SER) - parseInt(b.ZNOTE_SER) ); contractContent = sortedNotes .map(note => note.ZNOTE_TXT || '') .filter(text => text.trim() !== '') .join('\n\n'); } // 매핑 - SAP ECC 필드명과 함께 주석 추가 const mappedData: ContractData = { projectId, vendorId, contractNo: eccHeader.EBELN || '', // EBELN - 구매오더번호 contractName: eccHeader.ZTITLE || eccHeader.EBELN || '', // ZTITLE - 발주제목 contractContent, // 계약서 내용 status: eccHeader.ZPO_CNFM_STAT || 'ACTIVE', // ZPO_CNFM_STAT - 구매오더확인상태 startDate: parseDate(eccHeader.ZPO_DT || null), // ZPO_DT - 발주일자 endDate: null, // ZMM_DT에서 가져와야 함 deliveryDate: null, // ZMM_DT에서 가져와야 함 // SAP ECC 기본 필드들 paymentTerms: eccHeader.ZTERM || null, // ZTERM - 지급조건코드 deliveryTerms: eccHeader.INCO1 || null, // INCO1 - 인도조건코드 shippmentPlace: eccHeader.ZSHIPMT_PLC_CD || null, // ZSHIPMT_PLC_CD - 선적지코드 deliveryLocation: eccHeader.ZUNLD_PLC_CD || null, // ZUNLD_PLC_CD - 하역지코드 // SAP ECC 추가 필드들 poVersion: eccHeader.ZPO_VER ? parseInt(eccHeader.ZPO_VER) : null, // ZPO_VER - 발주버전 purchaseDocType: eccHeader.BSART || null, // BSART - 구매문서유형 purchaseOrg: eccHeader.EKORG || null, // EKORG - 구매조직코드 purchaseGroup: eccHeader.EKGRP || null, // EKGRP - 구매그룹코드 exchangeRate: eccHeader.WKURS ? parseAmount(eccHeader.WKURS) : null, // WKURS - 환율 poConfirmStatus: eccHeader.ZPO_CNFM_STAT || null, // ZPO_CNFM_STAT - 구매오더확인상태 // 계약/보증 관련 contractGuaranteeCode: eccHeader.ZCNRT_GRNT_CD || null, // ZCNRT_GRNT_CD - 계약보증코드 defectGuaranteeCode: eccHeader.ZDFCT_GRNT_CD || null, // ZDFCT_GRNT_CD - 하자보증코드 guaranteePeriodCode: eccHeader.ZGRNT_PRD_CD || null, // ZGRNT_PRD_CD - 보증기간코드 advancePaymentYn: eccHeader.ZPAMT_YN || null, // ZPAMT_YN - 선급금여부 // 금액 관련 budgetAmount: parseAmount(eccHeader.ZBGT_AMT || null), // ZBGT_AMT - 예산금액 budgetCurrency: eccHeader.ZBGT_CURR || null, // ZBGT_CURR - 예산금액 통화키 currency: eccHeader.ZPO_CURR || 'KRW', // ZPO_CURR - 통화키 totalAmount: parseAmount(eccHeader.ZPO_AMT || null), // ZPO_AMT - 발주금액 totalAmountKrw: parseAmount(eccHeader.ZPO_AMT_KRW || null), // ZPO_AMT_KRW - 발주금액 KRW // 전자계약/승인 관련 electronicContractYn: eccHeader.ZELC_CNRT_ND_YN || null, // ZELC_CNRT_ND_YN - 전자계약필요여부 electronicApprovalDate: parseDate(eccHeader.ZELC_AGR_DT || null), // ZELC_AGR_DT - 전자승인일자 electronicApprovalTime: eccHeader.ZELC_AGR_TM || null, // ZELC_AGR_TM - 전자승인시간 ownerApprovalYn: eccHeader.ZOWN_AGR_IND_YN || null, // ZOWN_AGR_IND_YN - 선주승인필요여부 // 기타 plannedInOutFlag: eccHeader.ZPLN_INO_GB || null, // ZPLN_INO_GB - 계획내외구분 settlementStandard: eccHeader.ZECAL_BSE || null, // ZECAL_BSE - 정산기준 weightSettlementFlag: eccHeader.ZWGT_ECAL_GB || null, // ZWGT_ECAL_GB - 중량정산구분 // 연동제 관련 priceIndexYn: eccHeader.ZDLV_PRICE_T || null, // ZDLV_PRICE_T - 납품대금연동제대상여부 writtenContractNo: eccHeader.ZWEBELN || null, // ZWEBELN - 서면계약번호 contractVersion: eccHeader.ZVER_NO ? parseInt(eccHeader.ZVER_NO) : null, // ZVER_NO - 서면계약차수 netTotal: parseAmount(eccHeader.ZPO_AMT || null), // ZPO_AMT와 동일 remarks: eccHeader.ETC_2 || null, // ETC_2 - 확장2 // 기본값들 discount: null, tax: null, shippingFee: null, partialShippingAllowed: false, partialPaymentAllowed: false, version: 1, }; debugSuccess('ECC PO 헤더 매핑 완료', { ebeln: eccHeader.EBELN }); return mappedData; } /** * ECC PO 상세 데이터를 contract_items 테이블로 매핑 */ export async function mapECCPODetailToBusiness( eccDetail: ECCPODetail, contractId: number ): Promise { debugLog('ECC PO 상세 매핑 시작', { ebeln: eccDetail.EBELN, ebelp: eccDetail.EBELP, matnr: eccDetail.MATNR, }); // itemId 찾기 또는 생성 (MATNR 기반으로 items 테이블에서 조회/삽입) let itemId: number | null = null; if (eccDetail.MATNR) { try { // 1. 먼저 기존 아이템 조회 const item = await db.query.items.findFirst({ where: eq(items.itemCode, eccDetail.MATNR), }); if (item) { itemId = item.id; debugLog('기존 아이템 ID 찾음', { matnr: eccDetail.MATNR, itemId, packageCode: item.packageCode }); } else { // 2. 아이템이 없으면 새로 생성 debugLog('아이템이 없어서 새로 생성', { matnr: eccDetail.MATNR }); // 프로젝트 정보 설정 const projectNo = eccDetail.PSPID || 'DEFAULT'; const packageCode = 'AUTO_GENERATED'; // 기본값으로 설정 const newItemData = { ProjectNo: projectNo, itemCode: eccDetail.MATNR, itemName: eccDetail.MAKTX || eccDetail.MATNR || 'Unknown Item', packageCode: packageCode, smCode: null, // SM 코드는 ECC 데이터에서 제공되지 않음 description: eccDetail.MAKTX || null, parentItemCode: null, itemLevel: null, deleteFlag: 'N', unitOfMeasure: null, steelType: null, gradeMaterial: null, changeDate: null, baseUnitOfMeasure: null, }; const [insertedItem] = await db.insert(items).values(newItemData).returning({ id: items.id }); itemId = insertedItem.id; debugSuccess('새 아이템 생성 완료', { matnr: eccDetail.MATNR, itemId, projectNo, itemName: newItemData.itemName }); } } catch (error) { debugError('아이템 조회/생성 중 오류', { matnr: eccDetail.MATNR, error }); } } // 수량 파싱 const parseQuantity = (qtyStr: string | null): number => { if (!qtyStr) return 1; try { const num = parseInt(qtyStr); return isNaN(num) ? 1 : num; } catch (error) { debugError('수량 파싱 오류', { qtyStr, error }); return 1; } }; // 금액 파싱 const parseAmount = (amountStr: string | null): string | null => { if (!amountStr) return null; try { const num = parseFloat(amountStr); return isNaN(num) ? null : num.toString(); } catch (error) { debugError('금액 파싱 오류', { amountStr, error }); return null; } }; // 세율 계산 (MWSKZ 기반) const calculateTaxRate = (mwskz: string | null): string | null => { if (!mwskz) return null; // 일반적인 한국 세율 매핑 (실제 비즈니스 로직에 따라 조정 필요) switch (mwskz) { case '10': return '10.00'; case '00': return '0.00'; default: return '10.00'; // 기본값 } }; const quantity = parseQuantity(eccDetail.MENGE || null); const unitPrice = parseAmount(eccDetail.NETPR || null); const taxRate = calculateTaxRate(eccDetail.MWSKZ || null); const totalLineAmount = parseAmount(eccDetail.NETWR || null); // 세액 계산 let taxAmount: string | null = null; if (unitPrice && taxRate) { try { const unitPriceNum = parseFloat(unitPrice); const taxRateNum = parseFloat(taxRate); const calculatedTaxAmount = (unitPriceNum * quantity * taxRateNum) / 100; taxAmount = calculatedTaxAmount.toString(); } catch (error) { debugError('세액(taxAmount) 계산 오류((unitPriceNum * quantity * taxRateNum) / 100)', { unitPrice, taxRate, quantity, error }); } } // 매핑 const mappedData: ContractItemData = { contractId, itemId: itemId!, // 아이템이 없으면 자동 생성되므로 null이 될 수 없음 description: eccDetail.MAKTX || null, quantity, unitPrice, taxRate, taxAmount, totalLineAmount, remark: eccDetail.ZPO_RMK || null, }; debugSuccess('ECC PO 상세 매핑 완료', { ebeln: eccDetail.EBELN, ebelp: eccDetail.EBELP, itemId, }); return mappedData; } /** * ECC PO 데이터를 비즈니스 테이블로 일괄 매핑 및 저장 */ export async function mapAndSaveECCPOData( processedPOs: ProcessedPOData[] ): Promise<{ success: boolean; message: string; processedCount: number }> { debugLog('ECC PO 데이터 일괄 매핑 및 저장 시작', { poCount: processedPOs.length, }); try { const result = await db.transaction(async (tx) => { let processedCount = 0; for (const poData of processedPOs) { const { header, details } = poData; try { // 1. 헤더 매핑 및 저장 const contractData = await mapECCPOHeaderToBusiness(header); // 중복 체크 (contractNo 기준) const existingContract = await tx.query.contracts.findFirst({ where: eq(contracts.contractNo, contractData.contractNo), }); let contractId: number; if (existingContract) { // 기존 계약 업데이트 await tx .update(contracts) .set({ ...contractData, updatedAt: new Date(), }) .where(eq(contracts.id, existingContract.id)); contractId = existingContract.id; debugLog('기존 계약 업데이트', { contractNo: contractData.contractNo, contractId }); } else { // 새 계약 생성 const [insertedContract] = await tx .insert(contracts) .values(contractData) .returning({ id: contracts.id }); contractId = insertedContract.id; debugLog('새 계약 생성', { contractNo: contractData.contractNo, contractId }); } // 2. 기존 contract_items 삭제 (교체 방식) await tx .delete(contractItems) .where(eq(contractItems.contractId, contractId)); // 3. 상세 매핑 및 저장 if (details.length > 0) { const contractItemsData: ContractItemData[] = []; for (const detail of details) { const itemData = await mapECCPODetailToBusiness(detail, contractId); contractItemsData.push(itemData); } // 일괄 삽입 await tx.insert(contractItems).values(contractItemsData); debugLog('계약 아이템 저장 완료', { contractId, itemCount: contractItemsData.length }); } processedCount++; } catch (error) { debugError('PO 데이터 처리 중 오류', { ebeln: header.EBELN, error }); // 개별 PO 처리 실패 시 해당 PO만 스킵하고 계속 진행 continue; } } return { processedCount }; }); debugSuccess('ECC PO 데이터 일괄 처리 완료', { processedCount: result.processedCount, }); return { success: true, message: `${result.processedCount}개의 PO 데이터가 성공적으로 처리되었습니다.`, processedCount: result.processedCount, }; } catch (error) { debugError('ECC PO 데이터 처리 중 오류 발생', error); return { success: false, message: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', processedCount: 0, }; } } /** * ECC PO 데이터 유효성 검증 */ export function validateECCPOData( processedPOs: ProcessedPOData[] ): { isValid: boolean; errors: string[] } { const errors: string[] = []; for (const poData of processedPOs) { const { header, details } = poData; // 헤더 데이터 검증 if (!header.EBELN) { errors.push(`필수 필드 누락: EBELN (구매오더번호)`); } if (!header.LIFNR) { errors.push(`필수 필드 누락: LIFNR (VENDOR코드) - EBELN: ${header.EBELN}`); } // 상세 데이터 검증 for (const detail of details) { if (!detail.EBELN) { errors.push(`필수 필드 누락: EBELN (구매오더번호) - EBELP: ${detail.EBELP}`); } if (!detail.EBELP) { errors.push(`필수 필드 누락: EBELP (구매오더품목번호) - EBELN: ${detail.EBELN}`); } if (!detail.MATNR) { errors.push(`필수 필드 누락: MATNR (자재코드) - EBELN: ${detail.EBELN}, EBELP: ${detail.EBELP}`); } } // 헤더와 상세 간의 관계 검증 for (const detail of details) { if (detail.EBELN !== header.EBELN) { errors.push(`헤더와 상세의 EBELN이 일치하지 않음: Header ${header.EBELN}, Detail ${detail.EBELN}`); } } } return { isValid: errors.length === 0, errors, }; }