summaryrefslogtreecommitdiff
path: root/lib/soap/ecc
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-20 11:53:08 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-20 11:53:08 +0900
commit01b7b192acc316b4f8969893d1d9bb6369425776 (patch)
treec885effdc80380ddabd4c52e9b24d98e8c9565e3 /lib/soap/ecc
parent77cbcaf27c9de8b361a6c5a13f0eefb37fd0d0e5 (diff)
(김준회) PO 및 RFQ 관련 구매 피드백 반영
- PO매핑 오류 수정, 스키마 컬럼추가, 숫자포매팅 등 - rfq 담당자 문제 수정 등
Diffstat (limited to 'lib/soap/ecc')
-rw-r--r--lib/soap/ecc/mapper/po-mapper.ts254
-rw-r--r--lib/soap/ecc/mapper/rfq-and-pr-mapper.ts3
2 files changed, 187 insertions, 70 deletions
diff --git a/lib/soap/ecc/mapper/po-mapper.ts b/lib/soap/ecc/mapper/po-mapper.ts
index 4f8b8034..fef85662 100644
--- a/lib/soap/ecc/mapper/po-mapper.ts
+++ b/lib/soap/ecc/mapper/po-mapper.ts
@@ -42,21 +42,33 @@ export async function mapECCPOHeaderToBusiness(
debugLog('ECC PO 헤더 매핑 시작', { ebeln: eccHeader.EBELN });
// projectId 찾기 (PSPID → projects.code 기반)
+ // PSPID가 없으면 불완전 데이터로 처리하고 에러 발생
let projectId: number | null = null;
- if (eccHeader.PSPID) {
- try {
- const project = await db.query.projects.findFirst({
- where: eq(projects.code, eccHeader.PSPID),
+ 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
});
- if (project) {
- projectId = project.id;
- debugLog('프로젝트 ID 찾음', { pspid: eccHeader.PSPID, projectId });
- } else {
- debugError('프로젝트를 찾을 수 없음', { pspid: eccHeader.PSPID });
- }
- } catch (error) {
- debugError('프로젝트 조회 중 오류', { pspid: eccHeader.PSPID, error });
+ throw new Error(`프로젝트를 찾을 수 없습니다: PSPID=${eccHeader.PSPID}, EBELN=${eccHeader.EBELN}`);
}
+ } catch (error) {
+ debugError('프로젝트 조회 중 오류', { pspid: eccHeader.PSPID, error });
+ throw error;
}
// vendorId 찾기 (LIFNR 기반)
@@ -104,14 +116,13 @@ export async function mapECCPOHeaderToBusiness(
}
};
- // projectId와 vendorId 필수 체크
- if (!projectId) {
- debugError('프로젝트를 찾을 수 없어 매핑을 건너뜁니다', { pspid: eccHeader.PSPID });
- throw new Error(`프로젝트를 찾을 수 없습니다: PSPID=${eccHeader.PSPID}`);
- }
+ // vendorId 필수 체크 (projectId는 위에서 이미 체크됨)
if (!vendorId) {
- debugError('벤더를 찾을 수 없어 매핑을 건너뜁니다', { lifnr: eccHeader.LIFNR });
- throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}`);
+ debugError('벤더를 찾을 수 없어 매핑을 건너뜁니다', {
+ lifnr: eccHeader.LIFNR,
+ ebeln: eccHeader.EBELN
+ });
+ throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}, EBELN=${eccHeader.EBELN}`);
}
// 계약서 내용 구성 (ZMM_NOTE에서 가져옴)
@@ -134,10 +145,12 @@ export async function mapECCPOHeaderToBusiness(
contractNo: eccHeader.EBELN || '', // EBELN - 구매오더번호
contractName: eccHeader.ZTITLE || eccHeader.EBELN || '', // ZTITLE - 발주제목
contractContent, // 계약서 내용
- status: eccHeader.ZPO_CNFM_STAT || 'ACTIVE', // ZPO_CNFM_STAT - 구매오더확인상태
+ // TODO: ZPO_CNFM_STAT 값을 ContractStatus enum으로 매핑하는 로직 필요
+ // 현재는 ECC에서 받은 값을 그대로 사용하되, null인 경우 undefined로 변환
+ status: eccHeader.ZPO_CNFM_STAT || undefined, // ZPO_CNFM_STAT - 구매오더확인상태
startDate: parseDate(eccHeader.ZPO_DT || null), // ZPO_DT - 발주일자
- endDate: null, // ZMM_DT에서 가져와야 함
- deliveryDate: null, // ZMM_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 - 지급조건코드
@@ -151,7 +164,8 @@ export async function mapECCPOHeaderToBusiness(
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 - 구매오더확인상태
+ 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 - 계약보증코드
@@ -162,7 +176,7 @@ export async function mapECCPOHeaderToBusiness(
// 금액 관련
budgetAmount: parseAmount(eccHeader.ZBGT_AMT || null), // ZBGT_AMT - 예산금액
budgetCurrency: eccHeader.ZBGT_CURR || null, // ZBGT_CURR - 예산금액 통화키
- currency: eccHeader.ZPO_CURR || 'KRW', // ZPO_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
@@ -185,12 +199,12 @@ export async function mapECCPOHeaderToBusiness(
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,
+ // 기본값들 (ECC 인터페이스에서 제공되지 않는 필드들)
+ discount: null, // TODO: 개별 품목별로는 할인 정보 있을 수 있음 (ZPDT_EXDS_AMT - 할인/할증금액)
+ tax: null, // TODO: 개별 품목별 세금 합산 필요
+ shippingFee: null, // TODO: 운송비 정보 (ZTRNS_UPR - 운송단가)가 있으나 헤더 레벨 집계 로직 필요
+ partialShippingAllowed: false, // ECC에서 제공되지 않음
+ partialPaymentAllowed: false, // ECC에서 제공되지 않음
version: 1,
};
@@ -231,25 +245,25 @@ export async function mapECCPODetailToBusiness(
// 2. 아이템이 없으면 새로 생성
debugLog('아이템이 없어서 새로 생성', { matnr: eccDetail.MATNR });
- // 프로젝트 정보 설정
- const projectNo = eccDetail.PSPID || 'DEFAULT';
- const packageCode = 'AUTO_GENERATED'; // 기본값으로 설정
+ // PSPID를 ProjectNo로 사용 (projects.code와 매핑됨)
+ // PSPID가 없으면 불완전 데이터이므로 이미 상위에서 에러 발생되어 여기까지 오지 않음
+ const projectNo = eccDetail.PSPID || 'UNKNOWN'; // notNull 필드이므로 기본값 필요
const newItemData = {
ProjectNo: projectNo,
itemCode: eccDetail.MATNR,
- itemName: eccDetail.MAKTX || eccDetail.MATNR || 'Unknown Item',
- packageCode: packageCode,
- smCode: null, // SM 코드는 ECC 데이터에서 제공되지 않음
+ 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: null,
- steelType: null,
- gradeMaterial: null,
+ unitOfMeasure: eccDetail.ZPO_UNIT || null, // ZPO_UNIT - 구매오더수량단위
+ steelType: null, // ECC 데이터에서 제공되지 않음
+ gradeMaterial: null, // ECC 데이터에서 제공되지 않음
changeDate: null,
- baseUnitOfMeasure: null,
+ baseUnitOfMeasure: eccDetail.BPRME || null, // BPRME - 구매단가단위
};
const [insertedItem] = await db.insert(items).values(newItemData).returning({ id: items.id });
@@ -291,49 +305,117 @@ export async function mapECCPODetailToBusiness(
}
};
- // 세율 계산 (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);
+
+ // 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;
+ }
+ };
- // 세액 계산
- let taxAmount: string | null = null;
- if (unitPrice && taxRate) {
+ // 정수 파싱
+ const parseInteger = (intStr: string | null): number | null => {
+ if (!intStr) return null;
try {
- const unitPriceNum = parseFloat(unitPrice);
- const taxRateNum = parseFloat(taxRate);
- const calculatedTaxAmount = (unitPriceNum * quantity * taxRateNum) / 100;
- taxAmount = calculatedTaxAmount.toString();
+ const num = parseInt(intStr);
+ return isNaN(num) ? null : num;
} catch (error) {
- debugError('세액(taxAmount) 계산 오류((unitPriceNum * quantity * taxRateNum) / 100)', { unitPrice, taxRate, quantity, 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,
- unitPrice,
+ 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,
- remark: eccDetail.ZPO_RMK || null,
+
+ // 위치 정보
+ 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 상세 매핑 완료', {
@@ -365,6 +447,39 @@ export async function mapAndSaveECCPOData(
// 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),
@@ -429,10 +544,10 @@ export async function mapAndSaveECCPOData(
}
processedCount++;
- } catch (error) {
+ } catch (err) {
debugError('PO 데이터 처리 중 오류', {
ebeln: header.EBELN,
- error
+ error: err
});
// 개별 PO 처리 실패 시 해당 PO만 스킵하고 계속 진행
continue;
@@ -509,3 +624,4 @@ export function validateECCPOData(
errors,
};
}
+
diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
index c0557d0c..c1c56cf3 100644
--- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
@@ -155,6 +155,7 @@ export async function mapECCRfqHeaderToRfqLast(
// 담당자 찾기
const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null);
const inChargeUserId = inChargeUserInfo?.userId || null;
+ const inChargeUserName = inChargeUserInfo?.userName || null; // 담당자명 추가
// 대표 PR Item 기반으로 projectId, itemCode, itemName, 설계담당자 설정 (없으면 첫번째 PR Item 사용)
let projectId: number | null = null;
@@ -219,7 +220,7 @@ export async function mapECCRfqHeaderToRfqLast(
remark: null,
pic: inChargeUserId, // 담당자 ID
picCode: eccHeader.EKGRP || null, // 구매그룹코드
- picName: null, // 담당자명은 별도 조회 필요
+ picName: inChargeUserName, // 담당자명 (EKGRP로 조회)
sentBy: null,
createdBy: inChargeUserId || 1,
updatedBy: inChargeUserId || 1,