summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-20 14:25:05 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-20 14:25:05 +0900
commitfdf72957d6ce5b5fec8e1b2999701d5f5a119cc3 (patch)
tree7f1dd370c8f51fe15df33c9d0f8bd34d35be9dec /lib
parent28b9664c3a6aab2786e2429b2a8ae57f557856e2 (diff)
(김준회) PCR 인터페이스 수신시 비즈니스테이블로 매핑 처리 및 필요 공통함수들 작성
Diffstat (limited to 'lib')
-rw-r--r--lib/soap/ecc/mapper/common-mapper-utils.ts113
-rw-r--r--lib/soap/ecc/mapper/pcr-mapper.ts353
-rw-r--r--lib/soap/utils.ts49
3 files changed, 503 insertions, 12 deletions
diff --git a/lib/soap/ecc/mapper/common-mapper-utils.ts b/lib/soap/ecc/mapper/common-mapper-utils.ts
index 8558f058..cf5e0fdb 100644
--- a/lib/soap/ecc/mapper/common-mapper-utils.ts
+++ b/lib/soap/ecc/mapper/common-mapper-utils.ts
@@ -12,14 +12,19 @@
import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
import db from '@/db/db';
-import { users } from '@/db/schema';
+import { users, vendors } from '@/db/schema';
import { projects } from '@/db/schema/projects';
import { EQUP_MASTER_MATL_CHARASGN } from '@/db/schema/MDG/mdg';
import { eq } from 'drizzle-orm';
+import { oracleKnex } from '@/lib/oracle-db/db';
/**
* 담당자 정보 조회 함수 (EKGRP 기반)
- * EKGRP(구매그룹코드)를 users 테이블의 userCode와 매칭하여 사용자 정보 반환
+ *
+ * 조회 순서:
+ * 1. Oracle DB에서 구매그룹코드(EKGRP)로 사번(EMPLOYEE_NUMBER) 조회
+ * 2. 사번으로 users 테이블의 employeeNumber와 매칭하여 사용자 정보 조회
+ * 3. 폴백: users 테이블의 userCode = EKGRP로 직접 조회
*/
export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{
userId: number;
@@ -35,8 +40,63 @@ export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{
return null;
}
- // users 테이블에서 userCode로 직접 조회
- const userResult = await db
+ // 1. Oracle DB에서 구매그룹코드로 사번 조회
+ try {
+ debugLog('Oracle DB에서 구매그룹코드로 사번 조회 시도', { EKGRP });
+
+ const oracleResult = await oracleKnex.raw(`
+ SELECT
+ CD.USR_DF_CHAR_9 AS EMPLOYEE_NUMBER
+ FROM CMCTB_CDNM NM
+ JOIN CMCTB_CD CD
+ ON NM.CD_CLF = CD.CD_CLF
+ AND NM.CD = CD.CD
+ AND NM.CD2 = CD.CD3
+ WHERE NM.CD_CLF = 'MMA070'
+ AND CD.CD = :ekgrp
+ AND CD.DEL_YN != 'Y'
+ `, { ekgrp: EKGRP });
+
+ const rows = (oracleResult.rows || oracleResult) as Array<Record<string, unknown>>;
+
+ if (rows && rows.length > 0 && rows[0].EMPLOYEE_NUMBER) {
+ const employeeNumber = String(rows[0].EMPLOYEE_NUMBER);
+ debugLog('Oracle에서 사번 조회 성공', { EKGRP, employeeNumber });
+
+ // 2. 사번으로 users 테이블에서 사용자 조회
+ const userByEmployeeNumber = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ phone: users.phone
+ })
+ .from(users)
+ .where(eq(users.employeeNumber, employeeNumber))
+ .limit(1);
+
+ if (userByEmployeeNumber.length > 0) {
+ const userInfo = {
+ userId: userByEmployeeNumber[0].id,
+ userName: userByEmployeeNumber[0].name,
+ userEmail: userByEmployeeNumber[0].email,
+ userPhone: userByEmployeeNumber[0].phone
+ };
+ debugSuccess('사번으로 담당자 찾음 (Oracle 경로)', { EKGRP, employeeNumber, userInfo });
+ return userInfo;
+ } else {
+ debugLog('사번에 해당하는 사용자를 찾을 수 없음, 폴백 시도', { employeeNumber });
+ }
+ } else {
+ debugLog('Oracle에서 구매그룹코드에 해당하는 사번을 찾을 수 없음, 폴백 시도', { EKGRP });
+ }
+ } catch (oracleError) {
+ debugError('Oracle 조회 중 오류, 폴백 시도', { EKGRP, error: oracleError });
+ }
+
+ // 3. 폴백: users 테이블에서 userCode로 직접 조회
+ debugLog('폴백: userCode로 직접 조회 시도', { EKGRP });
+ const userByUserCode = await db
.select({
id: users.id,
name: users.name,
@@ -47,18 +107,18 @@ export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{
.where(eq(users.userCode, EKGRP))
.limit(1);
- if (userResult.length === 0) {
- debugError('EKGRP에 해당하는 사용자를 찾을 수 없음', { EKGRP });
+ if (userByUserCode.length === 0) {
+ debugError('EKGRP에 해당하는 사용자를 찾을 수 없음 (모든 경로 실패)', { EKGRP });
return null;
}
const userInfo = {
- userId: userResult[0].id,
- userName: userResult[0].name,
- userEmail: userResult[0].email,
- userPhone: userResult[0].phone
+ userId: userByUserCode[0].id,
+ userName: userByUserCode[0].name,
+ userEmail: userByUserCode[0].email,
+ userPhone: userByUserCode[0].phone
};
- debugSuccess('담당자 찾음', { EKGRP, userInfo });
+ debugSuccess('담당자 찾음 (폴백 경로)', { EKGRP, userInfo });
return userInfo;
} catch (error) {
debugError('담당자 찾기 중 오류 발생', { EKGRP, error });
@@ -211,3 +271,34 @@ export interface ProjectInfo {
id: number;
name: string;
}
+
+/**
+ * 협력업체 코드(LIFNR)로 vendorId 찾기
+ * LIFNR = 벤더코드 (ex. A0001234)
+ * vendors 테이블의 vendorCode 필드와 비교하여 vendorId를 찾음
+ */
+export async function findVendorIdByLIFNR(lifnr: string | null | undefined): Promise<number | null> {
+ if (!lifnr || !lifnr.trim()) {
+ debugLog('LIFNR이 없음');
+ return null;
+ }
+
+ try {
+ // vendorCode 또는 vendorSapCode로 조회
+ const vendor = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.vendorCode, lifnr.trim()))
+ .limit(1);
+
+ if (vendor.length > 0) {
+ return vendor[0].id;
+ }
+
+ debugLog(`LIFNR ${lifnr}에 해당하는 vendor를 찾을 수 없음`);
+ return null;
+ } catch (error) {
+ debugError('Vendor 조회 중 오류 발생', { lifnr, error });
+ return null;
+ }
+} \ No newline at end of file
diff --git a/lib/soap/ecc/mapper/pcr-mapper.ts b/lib/soap/ecc/mapper/pcr-mapper.ts
new file mode 100644
index 00000000..2733d6d4
--- /dev/null
+++ b/lib/soap/ecc/mapper/pcr-mapper.ts
@@ -0,0 +1,353 @@
+/**
+ * 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_RSN (Request 사유)
+ * - detailsReason: REQUEST_CD (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<PcrPoData> {
+ 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<PcrPrData> {
+ 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<string, ECCPcrData[]>();
+
+ 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,
+ };
+}
+
diff --git a/lib/soap/utils.ts b/lib/soap/utils.ts
index 6c09edbe..57e3b280 100644
--- a/lib/soap/utils.ts
+++ b/lib/soap/utils.ts
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import db from "@/db/db";
import { soapLogs, type LogDirection, type SoapLogInsert } from "@/db/schema/SOAP/soap";
import { XMLBuilder } from 'fast-xml-parser'; // for object→XML 변환
+import { debugError } from "../debug-utils";
// XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성
export type ToXMLFields<T> = {
@@ -605,4 +606,50 @@ export function dateToSAPFormat(date: Date): { date: string; time: string } {
date: formatDateForSAP(date.toISOString().slice(0, 10)),
time: formatTimeForSAP(date.toTimeString().slice(0, 8))
};
-} \ No newline at end of file
+}
+
+/**
+ * SAP 날짜 문자열을 Date로 변환 (YYYYMMDD -> Date)
+ */
+export function parseSAPDate(dateStr: string | null | undefined): Date | null {
+ if (!dateStr || !dateStr.trim()) return null;
+
+ const trimmed = dateStr.trim();
+ if (trimmed.length !== 8) return null;
+
+ try {
+ const year = parseInt(trimmed.substring(0, 4), 10);
+ const month = parseInt(trimmed.substring(4, 6), 10) - 1; // 월은 0부터 시작
+ const day = parseInt(trimmed.substring(6, 8), 10);
+
+ const date = new Date(year, month, day);
+
+ // 유효한 날짜인지 확인
+ if (isNaN(date.getTime())) return null;
+
+ return date;
+ } catch (error) {
+ debugError('SAP 날짜 파싱 오류', { dateStr, error });
+ return null;
+ }
+}
+
+
+/**
+ * 숫자 문자열을 string으로 변환 (numeric 타입용)
+ * drizzle-orm의 numeric 타입은 string으로 저장됨
+ */
+export function parseNumericString(value: string | null | undefined): string | null {
+ if (!value || !value.trim()) return null;
+
+ try {
+ const trimmed = value.trim();
+ const parsed = parseFloat(trimmed);
+ // 유효한 숫자인지 확인
+ if (isNaN(parsed)) return null;
+ // 원본 문자열 반환 (또는 정제된 숫자 문자열)
+ return trimmed;
+ } catch {
+ return null;
+ }
+}