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'; import { ContractStatus } from '@/db/schema/contract'; // 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 기반) // PSPID가 없으면 불완전 데이터로 처리하고 에러 발생 let projectId: number | null = null; if (!eccHeader.PSPID) { debugError('PSPID 누락 - 불완전 데이터', { ebeln: eccHeader.EBELN, ztitle: eccHeader.ZTITLE }); throw new Error(`PSPID가 없는 불완전 데이터: EBELN=${eccHeader.EBELN}`); } 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, ebeln: eccHeader.EBELN }); throw new Error(`프로젝트를 찾을 수 없습니다: PSPID=${eccHeader.PSPID}, EBELN=${eccHeader.EBELN}`); } } catch (error) { debugError('프로젝트 조회 중 오류', { pspid: eccHeader.PSPID, error }); throw 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; } }; // vendorId 필수 체크 (projectId는 위에서 이미 체크됨) if (!vendorId) { debugError('벤더를 찾을 수 없어 매핑을 건너뜁니다', { lifnr: eccHeader.LIFNR, ebeln: eccHeader.EBELN }); throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}, EBELN=${eccHeader.EBELN}`); } // 계약서(발주서) 내용 구성 (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, // 계약서 내용 // // TODO: ZPO_CNFM_STAT 값을 ContractStatus enum으로 매핑하는 로직 필요 // ZPO_CNFM_STAT은 단순히 확인여부로 null or 'C' status: ContractStatus.CONTRACT_TRANSFER, // ECC에서 PO 전송받은 상태 startDate: parseDate(eccHeader.ZPO_DT || null), // ZPO_DT - 발주일자 endDate: null, // TODO: ZMM_DT의 ZPLN_ED_DT(예정종료일자) 중 최대값으로 계산 필요 deliveryDate: null, // TODO: ZMM_DT의 ZPO_DLV_DT(PO납기일자) 중 최대값으로 계산 필요 // 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 - 구매오더확인상태 (기존 필드) ZPO_CNFM_STAT: eccHeader.ZPO_CNFM_STAT || null, // SAP 구매오더확인상태 원본값 // 계약/보증 관련 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 || null, // ZPO_CURR - 통화키 (null 가능) 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 // 기본값들 (ECC 인터페이스에서 제공되지 않는 필드들) discount: null, // TODO: 개별 품목별로는 할인 정보 있을 수 있음 (ZPDT_EXDS_AMT - 할인/할증금액) tax: null, // TODO: 개별 품목별 세금 합산 필요 shippingFee: null, // TODO: 운송비 정보 (ZTRNS_UPR - 운송단가)가 있으나 헤더 레벨 집계 로직 필요 partialShippingAllowed: false, // ECC에서 제공되지 않음 partialPaymentAllowed: false, // ECC에서 제공되지 않음 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 }); // PSPID를 ProjectNo로 사용 (projects.code와 매핑됨) // PSPID가 없으면 불완전 데이터이므로 이미 상위에서 에러 발생되어 여기까지 오지 않음 const projectNo = eccDetail.PSPID || 'UNKNOWN'; // notNull 필드이므로 기본값 필요 const newItemData = { ProjectNo: projectNo, itemCode: eccDetail.MATNR, itemName: eccDetail.MAKTX || eccDetail.MATNR, // notNull 필드 packageCode: null, // nullable 필드 - ECC에서 제공되지 않으므로 null smCode: null, // ECC 데이터에서 제공되지 않음 description: eccDetail.MAKTX || null, parentItemCode: null, itemLevel: null, deleteFlag: 'N', unitOfMeasure: eccDetail.ZPO_UNIT || null, // ZPO_UNIT - 구매오더수량단위 steelType: null, // ECC 데이터에서 제공되지 않음 gradeMaterial: null, // ECC 데이터에서 제공되지 않음 changeDate: null, baseUnitOfMeasure: eccDetail.BPRME || null, // BPRME - 구매단가단위 }; 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; } }; const quantity = parseQuantity(eccDetail.MENGE || null); const unitPrice = parseAmount(eccDetail.NETPR || null); // SAP ECC 금액 필드 // 금액 관계: NETWR = BRTWR + ZPDT_EXDS_AMT // - BRTWR: 기본 오더총액 // - ZPDT_EXDS_AMT: 조정금액 (할인은 음수, 할증은 양수) // - NETWR: 최종 오더정가 const NETWR = parseAmount(eccDetail.NETWR || null); // 오더정가 (최종 정가) const BRTWR = parseAmount(eccDetail.BRTWR || null); // 오더총액 (기본 총액) const ZPDT_EXDS_AMT = parseAmount(eccDetail.ZPDT_EXDS_AMT || null); // 할인/할증금액 (조정금액) // 세율과 세액 - SAP에서 계산된 값이 있으면 그대로 사용 // SAP에서 세금 정보를 별도로 제공하지 않으므로, 일단 null로 설정 const taxRate: string | null = null; const taxAmount: string | null = null; // 세금코드 (MWSKZ - 매출부가가치세코드: V1, V2 등의 두자리 코드) const taxType = eccDetail.MWSKZ || null; // totalLineAmount는 최종 정가(NETWR)를 사용 const totalLineAmount = NETWR; // 금액은 SAP에서 이미 계산/검증되어 오므로 그대로 저장 // 금액 관계: NETWR = BRTWR + ZPDT_EXDS_AMT // MWSKZ(매출부가가치세코드)를 taxType에 매핑 // 각 코드(V1, V2 등)별 taxRate는 추후 별도로 관리될 예정 // 날짜 파싱 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 parseInteger = (intStr: string | null): number | null => { if (!intStr) return null; try { const num = parseInt(intStr); return isNaN(num) ? null : num; } catch (error) { debugError('정수 파싱 오류', { intStr, error }); return null; } }; // 매핑 const mappedData: ContractItemData = { contractId, itemId: itemId!, // 아이템이 없으면 자동 생성되므로 null이 될 수 없음 itemNo: eccDetail.EBELP || null, // EBELP - 구매오더품목번호 (품번) prNo: eccDetail.BANFN || null, // BANFN - 구매요청번호 (PR번호) prItemNo: eccDetail.BNFPO || null, // BNFPO - 구매요청품목번호 (PR 품번) materialGroup: eccDetail.MATKL || null, // MATKL - 자재그룹 weight: parseAmount(eccDetail.NTGEW || null), // NTGEW - 순중량 weightUnit: eccDetail.GEWEI || null, // GEWEI - 중량단위 totalWeight: parseAmount(eccDetail.BRGEW || null), // BRGEW - 총중량 description: eccDetail.MAKTX || null, quantity, ZPO_UNIT: eccDetail.ZPO_UNIT || null, // 구매오더수량단위 unitPrice, // NETPR - 구매단가 // 가격 관련 추가 필드 PEINH: parseInteger(eccDetail.PEINH || null), // 가격단위값 (예: 1, 10, 100) BPRME: eccDetail.BPRME || null, // 구매단가단위 (EA, KG 등) ZNETPR: parseAmount(eccDetail.ZNETPR || null), // 발주단가 ZREF_NETPR: parseAmount(eccDetail.ZREF_NETPR || null), // 참조단가 taxRate, taxAmount, taxType, // MWSKZ - 매출부가가치세코드 NETWR, // 오더정가 (최종 정가) BRTWR, // 오더총액 (기본 총액) ZPDT_EXDS_AMT, // 할인/할증금액 (조정금액) totalLineAmount, // 위치 정보 WERKS: eccDetail.WERKS || null, // 플랜트코드 LGORT: eccDetail.LGORT || null, // 저장위치 // RFQ 추적 ANFNR: eccDetail.ANFNR || null, // RFQ번호 ANFPS: eccDetail.ANFPS || null, // RFQ품목번호 // 자재 추적 ZPO_LOT_NO: eccDetail.ZPO_LOT_NO || null, // Steel Material Marking No // 볼륨 정보 VOLUM: parseAmount(eccDetail.VOLUM || null), // 볼륨 VOLEH: eccDetail.VOLEH || null, // 볼륨단위 // 날짜 정보 ZPO_DLV_DT: parseDate(eccDetail.ZPO_DLV_DT || null), // PO납기일자 ZPLN_ST_DT: parseDate(eccDetail.ZPLN_ST_DT || null), // 예정시작일자 ZPLN_ED_DT: parseDate(eccDetail.ZPLN_ED_DT || null), // 예정종료일자 LFDAT: parseDate(eccDetail.LFDAT || null), // PR Delivery Date ZRCV_DT: parseDate(eccDetail.ZRCV_DT || null), // 구매접수일자 // 기타 ZCON_IND: eccDetail.ZCON_IND || null, // 시리즈구분 (SS 등) 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); // Details에서 날짜 정보 집계 const parseDate = (dateStr: string | null): Date | 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 new Date(`${year}-${month}-${day}`); } catch { return null; } }; // 모든 details의 납기일자 중 최대값을 deliveryDate로 설정 const deliveryDates = details .map(d => parseDate(d.ZPO_DLV_DT || null)) .filter((date): date is Date => date !== null); if (deliveryDates.length > 0) { const maxDeliveryDate = new Date(Math.max(...deliveryDates.map(d => d.getTime()))); contractData.deliveryDate = maxDeliveryDate.toISOString().split('T')[0]; } // 모든 details의 예정종료일자 중 최대값을 endDate로 설정 const endDates = details .map(d => parseDate(d.ZPLN_ED_DT || null)) .filter((date): date is Date => date !== null); if (endDates.length > 0) { const maxEndDate = new Date(Math.max(...endDates.map(d => d.getTime()))); contractData.endDate = maxEndDate.toISOString().split('T')[0]; } // 중복 체크 (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) { // LOEKZ(삭제지시자)가 null이 아니면 스킵 if (detail.LOEKZ !== null && detail.LOEKZ !== undefined && detail.LOEKZ !== '') { debugLog('삭제 지시된 아이템 스킵', { ebeln: detail.EBELN, ebelp: detail.EBELP, loekz: detail.LOEKZ }); continue; } const itemData = await mapECCPODetailToBusiness(detail, contractId); contractItemsData.push(itemData); } // 일괄 삽입 (유효한 아이템이 있을 때만) if (contractItemsData.length > 0) { await tx.insert(contractItems).values(contractItemsData); debugLog('계약 아이템 저장 완료', { contractId, itemCount: contractItemsData.length }); } else { debugLog('저장할 유효한 계약 아이템 없음', { contractId }); } } processedCount++; } catch (err) { debugError('PO 데이터 처리 중 오류', { ebeln: header.EBELN, error: err }); // 개별 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, }; }