/** * PCR (Purchase Change Request) 데이터를 ECC에서 수신하여 * pcrPo, pcrPr 비즈니스 테이블로 매핑하는 mapper * * === ZMM_PCR 매핑 정보 === * * [pcrPo 매핑] * - vendorId: LIFNR (공급업체) 조회 * - changeType: PCR_TYPE (물량/Spec 변경 Type) * - details: ZZSPEC (Specification) * - project: PSPID (프로젝트 코드 문자열) * - pcrRequestDate: PCR_REQ_DATE (PCR 요청일자) * - poContractNumber: EBELN (구매오더) * - revItemNumber: EBELP (구매오더 품번) * - purchaseContractManager: EKGRP로 조회한 담당자명 (Oracle → employeeNumber → users.name) * - pcrCreator: NAME (설계담당명) * - poContractAmountBefore: NETWR (PO 금액) * - poContractAmountAfter: ECC에서 제공 안됨 (TODO: NETPR * QTY_A로 계산 가능) * - contractCurrency: WAERS (PO 통화) * - pcrReason: REQUEST_CD (Request 코드) * - detailsReason: REQUEST_RSN (Request 사유) * * [pcrPr 매핑] * - materialNumber: MATNR (자재번호) * - materialDetails: MAKTX (자재명) * - quantityBefore/After: QTY_B/A (변경 전/후 수량) * - weightBefore/After: T_WEIGHT_B/A (변경 전/후 Total 중량) * - subcontractorWeightBefore/After: S_WEIGHT_B/A (변경 전/후 사급 중량) * - supplierWeightBefore/After: C_WEIGHT_B/A (변경 전/후 도급 중량) * - specDrawingBefore: ZZSPEC (Specification) * - specDrawingAfter: ECC에서 제공 안됨 * - initialPoContractDate: ZACC_DT (구매담당자 PR 접수일) * - specChangeDate: ERDAT (물량 변경일) * - poContractModifiedDate: ZAEDAT (도면변경일) * - confirmationDate: LFDAT (PR 납품일) * - designManager: NAME (설계담당명) * - poContractNumber: EBELN (구매오더) * * [미사용 ZMM_PCR 필드] * - BANFN, BNFPO: 구매요청, 구매요청 품번 (PO 기준이므로 미사용) * - ZSPEC_NUM: POS (pcrPr 스키마에 해당 컬럼 없음) * - MEINS: 단위 (pcrPr 스키마에 해당 컬럼 없음) * - MEINS_W: 중량 단위 (pcrPr 스키마에 해당 컬럼 없음) * - NETPR, PEINH: PO 단가, Price Unit (금액 계산에 사용 가능하나 현재 미사용) * - POSID: WBS (PSPID 사용) * - EKGRP: 구매그룹 코드 (EKNAM 사용) * - DEPTCD, DEPTNM, EMPID: 설계부서/부서명/담당자ID (NAME 사용) * - NAME1: 공급업체명 (LIFNR로 조회하므로 미사용) * - ZPROC_IND: PR 상태 (미사용) * - WERKS: PLANT (미사용) * - DOKNR, DOKAR, DOKTL, DOKVR: ECC 내부 도면 정보 (미사용) * - ZCHG_NO: ECC 내부 PR 수정번호 (미사용) */ import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils'; import db from '@/db/db'; import { pcrPo, pcrPr } from '@/db/schema/pcr'; import { ZMM_PCR } from '@/db/schema/ECC/ecc'; import { eq } from 'drizzle-orm'; import { findVendorIdByLIFNR, findUserInfoByEKGRP, } from './common-mapper-utils'; import { parseSAPDate, parseNumericString } from '@/lib/soap/utils'; // ECC 데이터 타입 정의 export type ECCPcrData = typeof ZMM_PCR.$inferInsert; // 비즈니스 테이블 데이터 타입 정의 export type PcrPoData = typeof pcrPo.$inferInsert; export type PcrPrData = typeof pcrPr.$inferInsert; /** * PCR_TYPE을 원본 그대로 반환 * ECC에서 'DE' 등 예상하지 못한 다양한 코드가 들어올 수 있으므로 * 별도 매핑 없이 원본 값을 그대로 사용 */ function getPcrType(pcrType: string | null | undefined): string | null { if (!pcrType || !pcrType.trim()) return null; return pcrType.trim(); } /** * ECC PCR 데이터를 pcrPo 테이블로 매핑 * 같은 PCR_REQ를 가진 여러 레코드 중 대표 레코드(첫번째)를 기준으로 매핑 */ export async function mapECCPcrToPcrPo( eccPcrRecords: ECCPcrData[] ): Promise { debugLog('ECC PCR -> pcrPo 매핑 시작', { pcrReq: eccPcrRecords[0]?.PCR_REQ, recordCount: eccPcrRecords.length }); // 대표 레코드 (첫번째) const representative = eccPcrRecords[0]; if (!representative) { throw new Error('매핑할 PCR 레코드가 없습니다.'); } // 협력업체 ID 조회 const vendorId = await findVendorIdByLIFNR(representative.LIFNR); // 담당자 정보 조회 (EKGRP 기반) const userInfo = await findUserInfoByEKGRP(representative.EKGRP || null); const inChargeUserId = userInfo?.userId || 1; // 기본값: 시스템 사용자 const inChargeUserName = userInfo?.userName || null; // 담당자 이름 // PCR 요청일자 파싱 const pcrRequestDate = parseSAPDate(representative.PCR_REQ_DATE); if (!pcrRequestDate) { throw new Error('PCR_REQ_DATE가 유효하지 않습니다.'); } // 금액 매핑 // NETWR: PO 금액을 변경 전 금액으로 사용 // 변경 후 금액은 ECC에서 별도 필드로 제공되지 않음 // 필요시 NETPR(PO 단가) * QTY_A(변경 후 수량) 등으로 계산 가능. 우선 I/F 값을 쓰도록 함 const poContractAmountBefore = parseNumericString(representative.NETWR); // 매핑 const mappedData: PcrPoData = { vendorId, // LIFNR로 조회한 vendor ID pcrApprovalStatus: '승인대기', // 기본값: 승인대기 changeType: getPcrType(representative.PCR_TYPE) || 'OTHER', // PCR_TYPE 원본 값 사용 (Q, W, S, QW, DE 등) details: representative.ZZSPEC || null, // ZZSPEC: Specification project: representative.PSPID || null, // PSPID: 프로젝트 코드 (문자열) pcrRequestDate, // PCR_REQ_DATE: PCR 요청일자 poContractNumber: representative.EBELN || '', // EBELN: 구매오더 revItemNumber: representative.EBELP || null, // EBELP: 구매오더 품번 purchaseContractManager: inChargeUserName, // EKGRP로 조회한 담당자명 pcrCreator: representative.NAME || null, // NAME: 설계담당명 (PCR 생성자) poContractAmountBefore, // NETWR: PO 금액 poContractAmountAfter: null, // ECC에서 제공되지 않음. TODO: 필요시 NETPR * QTY_A로 계산 contractCurrency: representative.WAERS || 'KRW', // WAERS: PO 통화 pcrReason: representative.REQUEST_CD || null, // REQUEST_CD: Request 코드 detailsReason: representative.REQUEST_RSN || null, // REQUEST_RSN: Request 사유 rejectionReason: null, // ECC에서 제공되지 않음 pcrResponseDate: null, // ECC에서 제공되지 않음 (응답일은 나중에 업데이트) createdBy: inChargeUserId, // EKGRP로 조회한 담당자 ID (Oracle → employeeNumber → users.id) updatedBy: inChargeUserId, // EKGRP로 조회한 담당자 ID createdAt: new Date(), updatedAt: new Date(), }; debugSuccess('ECC PCR -> pcrPo 매핑 완료', { pcrReq: representative.PCR_REQ, poContractNumber: mappedData.poContractNumber, }); return mappedData; } /** * ECC PCR 데이터를 pcrPr 테이블로 매핑 */ export async function mapECCPcrToPcrPr( eccPcr: ECCPcrData ): Promise { debugLog('ECC PCR -> pcrPr 매핑 시작', { pcrReq: eccPcr.PCR_REQ, pcrReqSeq: eccPcr.PCR_REQ_SEQ, }); // 담당자 정보 조회 (EKGRP 기반) const userInfo = await findUserInfoByEKGRP(eccPcr.EKGRP || null); const inChargeUserId = userInfo?.userId || 1; // 기본값: 시스템 사용자 const mappedData: PcrPrData = { materialNumber: eccPcr.MATNR || '', // MATNR: 자재번호 materialDetails: eccPcr.MAKTX || null, // MAKTX: 자재명 quantityBefore: parseNumericString(eccPcr.QTY_B), // QTY_B: 변경 전 수량 quantityAfter: parseNumericString(eccPcr.QTY_A), // QTY_A: 변경 후 수량 weightBefore: parseNumericString(eccPcr.T_WEIGHT_B), // T_WEIGHT_B: 변경 전 Total 중량 weightAfter: parseNumericString(eccPcr.T_WEIGHT_A), // T_WEIGHT_A: 변경 후 Total 중량 subcontractorWeightBefore: parseNumericString(eccPcr.S_WEIGHT_B), // S_WEIGHT_B: 변경 전 사급 중량 subcontractorWeightAfter: parseNumericString(eccPcr.S_WEIGHT_A), // S_WEIGHT_A: 변경 후 사급 중량 supplierWeightBefore: parseNumericString(eccPcr.C_WEIGHT_B), // C_WEIGHT_B: 변경 전 도급 중량 supplierWeightAfter: parseNumericString(eccPcr.C_WEIGHT_A), // C_WEIGHT_A: 변경 후 도급 중량 specDrawingBefore: eccPcr.ZZSPEC || null, // 스펙도면 이전/이후는 제공해주는 게 없음 specDrawingAfter: eccPcr.ZZSPEC || null, // 일단 ZZSPEC에서 문자열 initialPoContractDate: parseSAPDate(eccPcr.ZACC_DT), // ZACC_DT: 구매담당자 PR 접수일 specChangeDate: parseSAPDate(eccPcr.ERDAT), // ERDAT: 물량 변경일 poContractModifiedDate: parseSAPDate(eccPcr.ZAEDAT), // ZAEDAT: 도면변경일 (PO/계약 수정일로 사용) confirmationDate: parseSAPDate(eccPcr.LFDAT), // LFDAT: PR 납품일 designManager: eccPcr.NAME || null, // NAME: 설계담당명 poContractNumber: eccPcr.EBELN || '', // EBELN: 구매오더 createdBy: inChargeUserId, // EKGRP로 조회한 담당자 ID (Oracle → employeeNumber → users.id) updatedBy: inChargeUserId, // EKGRP로 조회한 담당자 ID createdAt: new Date(), updatedAt: new Date(), }; debugSuccess('ECC PCR -> pcrPr 매핑 완료', { pcrReq: eccPcr.PCR_REQ, pcrReqSeq: eccPcr.PCR_REQ_SEQ, materialNumber: mappedData.materialNumber, }); return mappedData; } /** * ECC PCR 데이터를 pcrPo/pcrPr 테이블로 일괄 매핑 및 저장 */ export async function mapAndSaveECCPcrData( eccPcrRecords: ECCPcrData[] ): Promise<{ success: boolean; message: string; processedCount: number }> { debugLog('ECC PCR 데이터 일괄 매핑 및 저장 시작', { recordCount: eccPcrRecords.length, }); try { const result = await db.transaction(async (tx) => { // PCR_REQ별로 그룹핑 const pcrGroups = new Map(); for (const eccPcr of eccPcrRecords) { const pcrReq = eccPcr.PCR_REQ; if (!pcrReq) { debugError('PCR_REQ가 없는 레코드는 건너뜀', { eccPcr }); continue; } if (!pcrGroups.has(pcrReq)) { pcrGroups.set(pcrReq, []); } pcrGroups.get(pcrReq)!.push(eccPcr); } debugLog(`PCR_REQ 그룹 수: ${pcrGroups.size}`); let processedCount = 0; // PCR_REQ별로 처리 for (const [pcrReq, pcrRecords] of pcrGroups) { try { // 1) pcrPo 매핑 및 저장 const pcrPoData = await mapECCPcrToPcrPo(pcrRecords); // 기존에 동일한 poContractNumber가 있는지 확인 const existingPcrPo = await tx .select({ id: pcrPo.id }) .from(pcrPo) .where(eq(pcrPo.poContractNumber, pcrPoData.poContractNumber)) .limit(1); if (existingPcrPo.length > 0) { // 기존 레코드 업데이트 debugLog(`기존 PCR_PO 업데이트: ${pcrPoData.poContractNumber}`); await tx .update(pcrPo) .set({ ...pcrPoData, updatedAt: new Date(), }) .where(eq(pcrPo.id, existingPcrPo[0].id)); } else { // 신규 레코드 삽입 debugLog(`신규 PCR_PO 삽입: ${pcrPoData.poContractNumber}`); await tx .insert(pcrPo) .values(pcrPoData); } // 2) pcrPr 매핑 및 저장 (각 PCR_REQ_SEQ별로) const pcrPrDataList: PcrPrData[] = []; for (const eccPcr of pcrRecords) { const pcrPrData = await mapECCPcrToPcrPr(eccPcr); pcrPrDataList.push(pcrPrData); } // 기존 pcrPr 레코드 삭제 (동일한 poContractNumber) await tx .delete(pcrPr) .where(eq(pcrPr.poContractNumber, pcrPoData.poContractNumber)); // 신규 pcrPr 레코드 삽입 if (pcrPrDataList.length > 0) { await tx.insert(pcrPr).values(pcrPrDataList); debugLog(`PCR_PR ${pcrPrDataList.length}개 삽입 완료`); } processedCount++; } catch (error) { debugError(`PCR_REQ ${pcrReq} 처리 중 오류 발생`, { error }); // 트랜잭션 롤백을 위해 에러를 다시 throw throw error; } } return { processedCount }; }); debugSuccess('ECC PCR 데이터 일괄 처리 완료', { processedCount: result.processedCount, }); return { success: true, message: `${result.processedCount}개의 PCR 데이터가 성공적으로 처리되었습니다.`, processedCount: result.processedCount, }; } catch (error) { debugError('ECC PCR 데이터 처리 중 오류 발생', error); return { success: false, message: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', processedCount: 0, }; } } /** * ECC PCR 데이터 유효성 검증 */ export function validateECCPcrData( eccPcrRecords: ECCPcrData[] ): { isValid: boolean; errors: string[] } { const errors: string[] = []; for (const record of eccPcrRecords) { // 필수 필드 검증 if (!record.PCR_REQ) { errors.push(`필수 필드 누락: PCR_REQ`); } if (!record.PCR_REQ_SEQ) { errors.push(`필수 필드 누락: PCR_REQ_SEQ (PCR_REQ: ${record.PCR_REQ})`); } if (!record.PCR_REQ_DATE) { errors.push(`필수 필드 누락: PCR_REQ_DATE (PCR_REQ: ${record.PCR_REQ})`); } if (!record.EBELN) { errors.push(`필수 필드 누락: EBELN (PCR_REQ: ${record.PCR_REQ})`); } if (!record.EBELP) { errors.push(`필수 필드 누락: EBELP (PCR_REQ: ${record.PCR_REQ})`); } } return { isValid: errors.length === 0, errors, }; }