summaryrefslogtreecommitdiff
path: root/lib/soap
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-08 10:33:01 +0000
committerjoonhoekim <26rote@gmail.com>2025-09-08 10:33:01 +0000
commit10aa3d34bc599232af07d8a643c9938be14cb5bf (patch)
tree9e9a94e89642e80024647de175de6f217daab682 /lib/soap
parentf93493f68c9f368e10f1c3379f1c1384068e3b14 (diff)
(김준회) 입찰 인터페이스 처리, 자재그룹명 매핑 수정, 자재그룹 뷰 수정, 부서별 도메인 할당시 동기화 처리, 도메인 부서 할당 다이얼로그 부서목록 스크롤 처리, 삼성중공업 사용자 global search 개선
Diffstat (limited to 'lib/soap')
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts499
-rw-r--r--lib/soap/ecc/mapper/common-mapper-utils.ts210
-rw-r--r--lib/soap/ecc/mapper/rfq-and-pr-mapper.ts177
3 files changed, 740 insertions, 146 deletions
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
new file mode 100644
index 00000000..0ec9af0f
--- /dev/null
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -0,0 +1,499 @@
+/**
+ * pr 발행 후, pr을 묶어서 rfq, bidding 을 sap ecc에서 생성한 경우
+ * ZBSART = AB인 경우, 즉 bidding인 경우 해당 케이스를 soap으로 수신한 뒤 이 함수에서 헤더는 biddings 테이블에, 아이템은 prItemsForBidding 테이블에 매핑
+ * ZBSART = AN인 경우, 즉 rfq인 경우 해당 케이스를 soap으로 수신한 뒤 rfq-and-pr-mapper.ts 파일에서 매핑
+ */
+
+import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
+import db from '@/db/db';
+import {
+ biddings,
+ prItemsForBidding,
+} from '@/db/schema/bidding';
+import {
+ PR_INFORMATION_T_BID_HEADER,
+ PR_INFORMATION_T_BID_ITEM,
+} from '@/db/schema/ECC/ecc';
+import { inArray, max, sql } from 'drizzle-orm';
+import {
+ findUserInfoByEKGRP,
+ findProjectInfoByPSPID,
+ findMaterialNameByMATNR,
+ parseSAPDateTime,
+ parseSAPDateToString,
+} from './common-mapper-utils';
+
+// ECC 데이터 타입 정의
+export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert;
+export type ECCBidItem = typeof PR_INFORMATION_T_BID_ITEM.$inferInsert;
+
+// 비즈니스 테이블 데이터 타입 정의
+export type BiddingData = typeof biddings.$inferInsert;
+export type PrItemForBiddingData = typeof prItemsForBidding.$inferInsert;
+
+
+/**
+ * Bidding 코드 생성 함수 (배치 처리용)
+ * 형식: BID{EKGRP}{00001}
+ * 기존 ANFNR은 기존 biddingNumber 사용, 새로운 ANFNR만 새 코드 생성
+ */
+async function generateBiddingCodes(eccHeaders: ECCBidHeader[]): Promise<Map<string, string>> {
+ try {
+ debugLog('Bidding 코드 배치 생성 시작', { headerCount: eccHeaders.length });
+
+ const biddingCodeMap = new Map<string, string>();
+
+ // 1) 먼저 기존 ANFNR들의 biddingNumber 조회
+ const anfnrList = eccHeaders.map(h => h.ANFNR).filter(Boolean);
+ if (anfnrList.length > 0) {
+ const existingResult = await db
+ .select({
+ ANFNR: biddings.ANFNR,
+ biddingNumber: biddings.biddingNumber
+ })
+ .from(biddings)
+ .where(inArray(biddings.ANFNR, anfnrList));
+
+ // 기존 ANFNR들의 biddingNumber 매핑
+ for (const row of existingResult) {
+ if (row.ANFNR && row.biddingNumber) {
+ biddingCodeMap.set(row.ANFNR, row.biddingNumber);
+ }
+ }
+ }
+
+ // 2) 새로운 ANFNR들만 필터링 (기존에 없는 것들)
+ const newHeaders = eccHeaders.filter(header =>
+ header.ANFNR && !biddingCodeMap.has(header.ANFNR)
+ );
+
+ if (newHeaders.length === 0) {
+ debugSuccess('모든 ANFNR이 기존에 존재함', {
+ totalCodes: biddingCodeMap.size
+ });
+ return biddingCodeMap;
+ }
+
+ // 3) 새로운 ANFNR들을 EKGRP별로 그룹핑
+ const ekgrpGroups = new Map<string, ECCBidHeader[]>();
+ for (const header of newHeaders) {
+ const ekgrp = header.EKGRP || 'UNKNOWN';
+ if (!ekgrpGroups.has(ekgrp)) {
+ ekgrpGroups.set(ekgrp, []);
+ }
+ ekgrpGroups.get(ekgrp)!.push(header);
+ }
+
+ // 4) EKGRP별로 새 코드 생성
+ for (const [ekgrp, headers] of ekgrpGroups) {
+ // 해당 EKGRP의 현재 최대 시퀀스 조회
+ const maxResult = await db
+ .select({
+ maxBiddingNumber: max(biddings.biddingNumber)
+ })
+ .from(biddings)
+ .where(sql`${biddings.biddingNumber} LIKE ${`BID${ekgrp}%`}`);
+
+ let nextSeq = 1;
+ if (maxResult[0]?.maxBiddingNumber) {
+ const prefix = `BID${ekgrp}`;
+ const currentCode = maxResult[0].maxBiddingNumber;
+ if (currentCode.startsWith(prefix)) {
+ const seqPart = currentCode.substring(prefix.length);
+ const currentSeq = parseInt(seqPart, 10);
+ if (!isNaN(currentSeq)) {
+ nextSeq = currentSeq + 1;
+ }
+ }
+ }
+
+ // 동일 EKGRP 내에서 순차적으로 새 코드 생성
+ for (const header of headers) {
+ const seqString = nextSeq.toString().padStart(5, '0');
+ const biddingCode = `BID${ekgrp}${seqString}`;
+ biddingCodeMap.set(header.ANFNR || '', biddingCode);
+ nextSeq++; // 다음 시퀀스로 증가
+ }
+ }
+
+ debugSuccess('Bidding 코드 배치 생성 완료', {
+ totalCodes: biddingCodeMap.size,
+ newCodes: newHeaders.length,
+ existingCodes: eccHeaders.length - newHeaders.length
+ });
+ return biddingCodeMap;
+ } catch (error) {
+ debugError('Bidding 코드 배치 생성 중 오류 발생', { error });
+
+ // 오류 발생시 폴백: ANFNR 기반 코드 생성
+ const fallbackMap = new Map<string, string>();
+ eccHeaders.forEach((header, index) => {
+ const ekgrp = header.EKGRP || 'UNKNOWN';
+ const seqString = (index + 1).toString().padStart(5, '0');
+ fallbackMap.set(header.ANFNR, `BID${ekgrp}${seqString}`);
+ });
+ return fallbackMap;
+ }
+}
+
+
+
+// *****************************mapping functions*********************************
+
+/**
+ * ECC Bidding 헤더 데이터를 biddings 테이블로 매핑
+ */
+export async function mapECCBiddingHeaderToBidding(
+ eccHeader: ECCBidHeader,
+ eccItems: ECCBidItem[],
+ biddingNumber: string
+): Promise<BiddingData> {
+ debugLog('ECC Bidding 헤더 매핑 시작', { anfnr: eccHeader.ANFNR, biddingNumber });
+
+ // 첫번째 PR Item 가져오기 (관련 아이템들 중 첫번째)
+ const firstItem = eccItems.find(item => item.ANFNR === eccHeader.ANFNR);
+
+ // 날짜 파싱 (실패시 현재 Date 들어감)
+ const createdAt = parseSAPDateTime(eccHeader.ZRFQ_TRS_DT || null, eccHeader.ZRFQ_TRS_TM || null);
+
+ // 담당자 찾기
+ const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null);
+
+ // 첫번째 PR Item 기반으로 projectId, projectName, itemName 설정
+ let projectId: number | null = null;
+ let projectName: string | null = null;
+ let itemName: string | null = null;
+ let prNumber: string | null = null;
+
+ if (firstItem) {
+ // projectId, projectName: 첫번째 PR Item의 PSPID와 projects.code 매칭
+ const projectInfo = await findProjectInfoByPSPID(firstItem.PSPID || null);
+ if (projectInfo) {
+ projectId = projectInfo.id;
+ projectName = projectInfo.name;
+ }
+
+ // itemName: 첫번째 PR Item의 MATNR로 MDG에서 ZZNAME 조회
+ itemName = await findMaterialNameByMATNR(firstItem.MATNR || null);
+
+ // prNumber: 첫번째 PR의 ZREQ_FN 값
+ prNumber = firstItem.ZREQ_FN || null;
+ }
+
+ // 매핑
+ const mappedData: BiddingData = {
+ biddingNumber, // 생성된 Bidding 코드
+ revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정)
+ projectId, // 첫번째 PR Item의 PSPID로 찾은 프로젝트 ID
+ projectName, // 첫번째 PR Item의 PSPID로 찾은 프로젝트 이름
+ itemName, // 첫번째 PR Item의 MATNR로 조회한 자재명
+ title: `${firstItem?.PSPID || ''}${itemName || ''}입찰`, // PSPID+자재그룹명+계약구분+입찰, 계약구분은 없으니까 제외했음
+ description: null,
+ content: null,
+
+ // 계약 정보 - ECC에서 제공되지 않으므로 기본값 설정
+ contractType: 'general', // 일반계약 기본값 (notNull)
+ biddingType: 'equipment', // 입찰유형 기본값 (notNull)
+ awardCount: 'single', // 낙찰수 기본값 (notNull)
+ contractPeriod: null, // ECC에서 제공 X
+
+ // 일정 관리 - ECC에서 제공되지 않음
+ preQuoteDate: null,
+ biddingRegistrationDate: null,
+ submissionStartDate: null,
+ submissionEndDate: null,
+ evaluationDate: null,
+
+ // 사양설명회
+ hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리
+
+ // 예산 및 가격 정보
+ currency: firstItem?.WAERS1,
+ budget: null,
+ targetPrice: null,
+ targetPriceCalculationCriteria: null,
+ finalBidPrice: null,
+
+ // PR 정보
+ prNumber, // 첫번째 PR의 ZREQ_FN 값
+ hasPrDocument: false, // PR문서는 POS를 말하는 것으로 보임.
+
+ // 상태 및 설정
+ status: 'bidding_generated', // 입찰생성 상태
+ isPublic: false,
+
+ // 담당자 정보 - EKGRP 기반으로 설정
+ managerName: inChargeUserInfo?.userName || null,
+ managerEmail: null,
+ managerPhone: inChargeUserInfo?.userPhone || null,
+
+ // 메타 정보
+ remarks: `ECC ANFNR: ${eccHeader.ANFNR}`,
+ createdBy: inChargeUserInfo?.userId?.toString() || '1',
+ createdAt,
+ updatedAt: createdAt,
+ updatedBy: inChargeUserInfo?.userId?.toString() || '1',
+ ANFNR: eccHeader.ANFNR, // 원본 ANFNR 추적용
+ };
+
+ debugSuccess('ECC Bidding 헤더 매핑 완료', { anfnr: eccHeader.ANFNR, biddingNumber });
+ return mappedData;
+}
+
+/**
+ * ECC Bidding 아이템 데이터를 prItemsForBidding 테이블로 매핑
+ */
+export function mapECCBiddingItemToPrItemForBidding(
+ eccItem: ECCBidItem,
+ biddingId: number
+): PrItemForBiddingData {
+ debugLog('ECC Bidding 아이템 매핑 시작', {
+ anfnr: eccItem.ANFNR,
+ anfps: eccItem.ANFPS,
+ });
+
+ // 날짜 파싱 (YYYY-MM-DD 형식의 문자열로 변환)
+ const requestedDeliveryDate = parseSAPDateToString(eccItem.LFDAT || null);
+
+ const mappedData: PrItemForBiddingData = {
+ biddingId, // 부모 Bidding ID
+ itemNumber: eccItem.ANFPS || null, // 아이템 번호
+ projectInfo: eccItem.PSPID || null, // 프로젝트 정보 (PSPID)
+ itemInfo: eccItem.TXZ01 || null, // 품목정보 (자재 설명)
+ shi: null, // ECC에서 제공되지 않음
+ requestedDeliveryDate, // 납품요청일
+ annualUnitPrice: null, // ECC에서 제공되지 않음
+ currency: eccItem.WAERS1 || null, // 기본 통화
+ quantity: eccItem.MENGE || null, // 수량
+ quantityUnit: eccItem.MEINS || null, // 수량단위
+ totalWeight: eccItem.BRGEW || null, // 총중량
+ weightUnit: eccItem.GEWEI || null, // 중량단위
+ materialDescription: eccItem.TXZ01 || null, // 자재내역상세
+ prNumber: eccItem.BANFN || null, // PR번호
+ hasSpecDocument: false, // 기본값 false
+ };
+
+ debugSuccess('ECC Bidding 아이템 매핑 완료', {
+ itemNumber: eccItem.ANFPS,
+ prNumber: eccItem.BANFN,
+ });
+ return mappedData;
+}
+
+/**
+ * ECC 데이터를 biddings/prItemsForBidding 테이블로 일괄 매핑 및 저장
+ */
+export async function mapAndSaveECCBiddingData(
+ eccHeaders: ECCBidHeader[],
+ eccItems: ECCBidItem[]
+): Promise<{ success: boolean; message: string; processedCount: number }> {
+ debugLog('ECC Bidding 데이터 일괄 매핑 및 저장 시작', {
+ headerCount: eccHeaders.length,
+ itemCount: eccItems.length,
+ });
+
+ // ANFNR이 없는 헤더들 필터링
+ const validHeaders = eccHeaders.filter(header => {
+ if (!header.ANFNR) {
+ debugError('ANFNR이 없는 헤더 스킵', { header });
+ return false;
+ }
+ return true;
+ });
+
+ if (validHeaders.length === 0) {
+ debugError('처리할 유효한 헤더가 없음');
+ return {
+ success: false,
+ message: '처리할 유효한 ANFNR이 있는 헤더가 없습니다.',
+ processedCount: 0,
+ };
+ }
+
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1) Bidding 코드 배치 생성 (중복 방지) - 유효한 헤더만 사용
+ const biddingCodeMap = await generateBiddingCodes(validHeaders);
+
+ // 2) 헤더별 관련 아이템 그룹핑 + 헤더 매핑을 병렬로 수행 - 유효한 헤더만 사용
+ const biddingGroups = await Promise.all(
+ validHeaders.map(async (eccHeader) => {
+ const relatedItems = eccItems.filter((item) => item.ANFNR === eccHeader.ANFNR);
+ const biddingNumber = biddingCodeMap.get(eccHeader.ANFNR) || `BID${eccHeader.EKGRP || 'UNKNOWN'}00001`;
+ // 헤더 매핑 시 아이템 정보와 생성된 Bidding 코드 전달
+ const biddingData = await mapECCBiddingHeaderToBidding(eccHeader, relatedItems, biddingNumber);
+ return { biddingNumber: biddingData.biddingNumber, biddingData, relatedItems };
+ })
+ );
+
+ const biddingRecords = biddingGroups.map((g) => g.biddingData);
+
+ // 3) Bidding 다건 삽입 (중복은 무시). 반환된 레코드로 일부 ID 매핑
+ // ANFNR 기반으로 중복 방지
+ const inserted = await tx
+ .insert(biddings)
+ .values(biddingRecords)
+ .onConflictDoUpdate({
+ target: biddings.ANFNR,
+ set: {
+ updatedAt: new Date(),
+ // ANFNR이 같으면 기존 데이터의 주요 필드들을 업데이트
+ biddingNumber: sql`EXCLUDED."bidding_number"`,
+ projectId: sql`EXCLUDED."project_id"`,
+ projectName: sql`EXCLUDED."project_name"`,
+ itemName: sql`EXCLUDED."item_name"`,
+ title: sql`EXCLUDED."title"`,
+ currency: sql`EXCLUDED."currency"`,
+ prNumber: sql`EXCLUDED."pr_number"`,
+ managerName: sql`EXCLUDED."manager_name"`,
+ managerPhone: sql`EXCLUDED."manager_phone"`,
+ remarks: sql`EXCLUDED."remarks"`,
+ createdBy: sql`EXCLUDED."created_by"`,
+ updatedBy: sql`EXCLUDED."updated_by"`,
+ }
+ })
+ .returning({ id: biddings.id, biddingNumber: biddings.biddingNumber });
+
+ const biddingNumberToId = new Map<string, number>();
+ for (const row of inserted) {
+ if (row.biddingNumber) {
+ biddingNumberToId.set(row.biddingNumber, row.id);
+ }
+ }
+
+ // 4) 모든 Bidding 코드에 대한 ID 매핑 보완 (업데이트된 경우 포함)
+ const allCodes = biddingRecords
+ .map((r) => r.biddingNumber)
+ .filter((c): c is string => typeof c === 'string' && c.length > 0);
+ const missingCodes = allCodes.filter((c) => !biddingNumberToId.has(c));
+ if (missingCodes.length > 0) {
+ const existing = await tx
+ .select({ id: biddings.id, biddingNumber: biddings.biddingNumber })
+ .from(biddings)
+ .where(inArray(biddings.biddingNumber, missingCodes));
+ for (const row of existing) {
+ if (row.biddingNumber) {
+ biddingNumberToId.set(row.biddingNumber, row.id);
+ }
+ }
+ }
+
+ // 5) 모든 아이템을 한 번에 생성할 데이터로 변환
+ const allItemsToInsert: PrItemForBiddingData[] = [];
+ for (const group of biddingGroups) {
+ const biddingNumber = group.biddingNumber;
+ if (!biddingNumber) continue;
+ const biddingId = biddingNumberToId.get(biddingNumber);
+ if (!biddingId) {
+ debugError('Bidding ID 매핑 누락', { biddingNumber });
+ throw new Error(`Bidding ID를 찾을 수 없습니다: ${biddingNumber}`);
+ }
+
+ for (const eccItem of group.relatedItems) {
+ const itemData = mapECCBiddingItemToPrItemForBidding(eccItem, biddingId);
+ allItemsToInsert.push(itemData);
+ }
+ }
+
+ // 6) 기존 아이템들 삭제 후 새로 삽입 (upsert 대신 replace 방식)
+ const updatedBiddingIds = Array.from(new Set(
+ allItemsToInsert.map(item => item.biddingId).filter(id => id !== undefined)
+ ));
+
+ if (updatedBiddingIds.length > 0) {
+ // 기존 아이템들 삭제
+ await tx
+ .delete(prItemsForBidding)
+ .where(inArray(prItemsForBidding.biddingId, updatedBiddingIds));
+ }
+
+ // 아이템 일괄 삽입 (chunk 처리로 파라미터 제한 회피)
+ const ITEM_CHUNK_SIZE = 1000;
+ for (let i = 0; i < allItemsToInsert.length; i += ITEM_CHUNK_SIZE) {
+ const chunk = allItemsToInsert.slice(i, i + ITEM_CHUNK_SIZE);
+ await tx.insert(prItemsForBidding).values(chunk);
+ }
+
+ return { processedCount: biddingRecords.length };
+ });
+
+ debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
+ processedCount: result.processedCount,
+ });
+
+ return {
+ success: true,
+ message: `${result.processedCount}개의 Bidding 데이터가 성공적으로 처리되었습니다.`,
+ processedCount: result.processedCount,
+ };
+ } catch (error) {
+ debugError('ECC Bidding 데이터 처리 중 오류 발생', error);
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : '알 수 없는 오류가 발생했습니다.',
+ processedCount: 0,
+ };
+ }
+}
+
+/**
+ * ECC Bidding 데이터 유효성 검증
+ */
+export function validateECCBiddingData(
+ eccHeaders: ECCBidHeader[],
+ eccItems: ECCBidItem[]
+): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // 헤더 데이터 검증
+ for (const header of eccHeaders) {
+ if (!header.ANFNR) {
+ errors.push(`필수 필드 누락: ANFNR (Bidding Number)`);
+ }
+ if (!header.ZBSART) {
+ errors.push(
+ `필수 필드 누락: ZBSART (Bidding Type) - ANFNR: ${header.ANFNR}`
+ );
+ }
+ }
+
+ // 아이템 데이터 검증
+ for (const item of eccItems) {
+ if (!item.ANFNR) {
+ errors.push(
+ `필수 필드 누락: ANFNR (Bidding Number) - Item: ${item.ANFPS}`
+ );
+ }
+ if (!item.ANFPS) {
+ errors.push(`필수 필드 누락: ANFPS (Item Number) - ANFNR: ${item.ANFNR}`);
+ }
+ if (!item.BANFN) {
+ errors.push(
+ `필수 필드 누락: BANFN (Purchase Requisition Number) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
+ );
+ }
+ if (!item.BANPO) {
+ errors.push(
+ `필수 필드 누락: BANPO (Item Number of Purchase Requisition) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
+ );
+ }
+ }
+
+ // 헤더와 아이템 간의 관계 검증
+ const headerAnfnrs = new Set(eccHeaders.map((h) => h.ANFNR));
+ const itemAnfnrs = new Set(eccItems.map((i) => i.ANFNR));
+
+ for (const anfnr of itemAnfnrs) {
+ if (!headerAnfnrs.has(anfnr)) {
+ errors.push(`아이템의 ANFNR이 헤더에 존재하지 않음: ${anfnr}`);
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ };
+} \ No newline at end of file
diff --git a/lib/soap/ecc/mapper/common-mapper-utils.ts b/lib/soap/ecc/mapper/common-mapper-utils.ts
new file mode 100644
index 00000000..526decb5
--- /dev/null
+++ b/lib/soap/ecc/mapper/common-mapper-utils.ts
@@ -0,0 +1,210 @@
+/**
+ * ECC Mapper 공통 유틸리티 함수들
+ * bidding-and-pr-mapper.ts와 rfq-and-pr-mapper.ts에서 공통으로 사용하는 함수들
+ *
+ * 1. 담당자 정보 조회 함수: 구매그룹코드로 id, email, phone 반환
+ * 2. 프로젝트 정보 조회 함수: 프로젝트 코드로 id, name 반환
+ * 3. 자재명 조회 함수: 자재코드로 자재명 반환
+ * 4. SAP 날짜/시간 파싱 함수: SAP 날짜/시간 문자열을 JavaScript Date로 변환
+ * 5. SAP 날짜를 YYYY-MM-DD 문자열로 변환 함수: SAP 날짜 문자열을 YYYY-MM-DD 문자열로 변환
+ *
+ */
+
+import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { users } from '@/db/schema';
+import { projects } from '@/db/schema/projects';
+import { EQUP_MASTER_MATL_CHARASGN } from '@/db/schema/MDG/mdg';
+import { eq } from 'drizzle-orm';
+
+/**
+ * 담당자 정보 조회 함수 (EKGRP 기반)
+ * EKGRP(구매그룹코드)를 users 테이블의 userCode와 매칭하여 사용자 정보 반환
+ */
+export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{
+ userId: number;
+ userName: string;
+ userPhone: string | null;
+} | null> {
+ try {
+ debugLog('담당자 찾기 시작', { EKGRP });
+
+ if (!EKGRP) {
+ debugError('EKGRP가 null 또는 undefined', { EKGRP });
+ return null;
+ }
+
+ // users 테이블에서 userCode로 직접 조회
+ const userResult = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ phone: users.phone
+ })
+ .from(users)
+ .where(eq(users.userCode, EKGRP))
+ .limit(1);
+
+ if (userResult.length === 0) {
+ debugError('EKGRP에 해당하는 사용자를 찾을 수 없음', { EKGRP });
+ return null;
+ }
+
+ const userInfo = {
+ userId: userResult[0].id,
+ userName: userResult[0].name,
+ userPhone: userResult[0].phone
+ };
+ debugSuccess('담당자 찾음', { EKGRP, userInfo });
+ return userInfo;
+ } catch (error) {
+ debugError('담당자 찾기 중 오류 발생', { EKGRP, error });
+ return null;
+ }
+}
+
+/**
+ * 프로젝트 정보 조회 함수 (PSPID 기반)
+ * PSPID와 projects.code 매칭하여 프로젝트 ID와 이름 반환
+ */
+export async function findProjectInfoByPSPID(PSPID: string | null): Promise<{
+ id: number;
+ name: string
+} | null> {
+ try {
+ debugLog('프로젝트 정보 찾기 시작', { PSPID });
+
+ if (!PSPID) {
+ debugError('PSPID가 null 또는 undefined', { PSPID });
+ return null;
+ }
+
+ const projectResult = await db
+ .select({
+ id: projects.id,
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.code, PSPID))
+ .limit(1);
+
+ if (projectResult.length === 0) {
+ debugError('PSPID에 해당하는 프로젝트를 찾을 수 없음', { PSPID });
+ return null;
+ }
+
+ const projectInfo = {
+ id: projectResult[0].id,
+ name: projectResult[0].name
+ };
+ debugSuccess('프로젝트 정보 찾음', { PSPID, projectInfo });
+ return projectInfo;
+ } catch (error) {
+ debugError('프로젝트 정보 찾기 중 오류 발생', { PSPID, error });
+ return null;
+ }
+}
+
+/**
+ * 자재명 조회 함수 (MATNR 기반)
+ * MATNR을 기반으로 EQUP_MASTER_MATL_CHARASGN 테이블에서 ATWTB 조회
+ *
+ * 주의: MATERIAL_MASTER_PART_MATL.ZZNAME이 아닌 EQUP_MASTER_MATL_CHARASGN.ATWTB를 사용해야 함
+ */
+export async function findMaterialNameByMATNR(MATNR: string | null): Promise<string | null> {
+ try {
+ debugLog('자재명 조회 시작', { MATNR });
+
+ if (!MATNR) {
+ debugError('MATNR이 null 또는 undefined', { MATNR });
+ return null;
+ }
+
+ const materialResult = await db
+ .select({ ATWTB: EQUP_MASTER_MATL_CHARASGN.ATWTB })
+ .from(EQUP_MASTER_MATL_CHARASGN)
+ .where(eq(EQUP_MASTER_MATL_CHARASGN.MATNR, MATNR))
+ .limit(1);
+
+ if (materialResult.length === 0) {
+ debugError('MATNR에 해당하는 자재를 찾을 수 없음', { MATNR });
+ return null;
+ }
+
+ const materialName = materialResult[0].ATWTB;
+ debugSuccess('자재명 조회 완료', { MATNR, materialName });
+ return materialName;
+ } catch (error) {
+ debugError('자재명 조회 중 오류 발생', { MATNR, error });
+ return null;
+ }
+}
+
+/**
+ * SAP 날짜/시간 파싱 함수
+ * SAP 형식 (YYYYMMDD + HHMMSS)을 JavaScript Date로 변환
+ */
+export function parseSAPDateTime(
+ dateStr: string | null,
+ timeStr: string | null
+): Date {
+ let parsedDate = new Date();
+
+ if (dateStr && timeStr) {
+ try {
+ // SAP 날짜 형식 (YYYYMMDD) 파싱
+ if (dateStr.length === 8) {
+ const year = parseInt(dateStr.substring(0, 4));
+ const month = parseInt(dateStr.substring(4, 6)) - 1; // 0-based
+ const day = parseInt(dateStr.substring(6, 8));
+ const hour = parseInt(timeStr.substring(0, 2));
+ const minute = parseInt(timeStr.substring(2, 4));
+ const second = parseInt(timeStr.substring(4, 6));
+ parsedDate = new Date(year, month, day, hour, minute, second);
+ }
+ } catch (error) {
+ debugError('SAP 날짜 파싱 오류', {
+ date: dateStr,
+ time: timeStr,
+ error,
+ });
+ }
+ }
+
+ return parsedDate;
+}
+
+/**
+ * SAP 날짜를 YYYY-MM-DD 문자열로 변환
+ * YYYYMMDD 형식을 YYYY-MM-DD로 변환
+ */
+export function parseSAPDateToString(dateStr: string | null): string | null {
+ if (!dateStr) return null;
+
+ try {
+ if (dateStr.length === 8) {
+ 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('SAP 날짜 문자열 파싱 오류', { date: dateStr, error });
+ }
+
+ return null;
+}
+
+/**
+ * 공통 타입 정의
+ */
+export interface UserInfo {
+ userId: number;
+ userName: string;
+ userPhone: string | null;
+}
+
+export interface ProjectInfo {
+ id: number;
+ name: string;
+}
diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
index e8697bae..7d1c2ab8 100644
--- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
@@ -1,3 +1,9 @@
+/**
+ * pr 발행 후, pr을 묶어서 rfq, bidding 을 sap ecc에서 생성한 경우
+ * ZBSART = AN인 경우, 즉 rfq인 경우 해당 케이스를 soap으로 수신한 뒤 이 함수에서 rqfLast, rfqPrItems 테이블에 매핑
+ * bidding인 경우는 bidding-and-pr-mapper.ts 파일에서 매핑
+ */
+
import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
import db from '@/db/db';
import {
@@ -8,10 +14,15 @@ import {
PR_INFORMATION_T_BID_HEADER,
PR_INFORMATION_T_BID_ITEM,
} from '@/db/schema/ECC/ecc';
-import { users } from '@/db/schema';
-import { projects } from '@/db/schema/projects';
-import { MATERIAL_MASTER_PART_MATL } from '@/db/schema/MDG/mdg';
import { eq, inArray, max, sql } from 'drizzle-orm';
+import {
+ findUserInfoByEKGRP,
+ findProjectInfoByPSPID,
+ findMaterialNameByMATNR,
+ parseSAPDateTime,
+ type UserInfo,
+ type ProjectInfo,
+} from './common-mapper-utils';
// ECC 데이터 타입 정의
export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert;
@@ -43,39 +54,6 @@ function computeSeriesFromItems(items: ECCBidItem[]): string | null {
return uniquePspids.size === 1 ? 'SS' : '||';
}
-/**
- * 담당자 찾는 함수 (userCode 기반으로 직접 조회)
- * EKGRP(구매그룹코드)를 users 테이블의 userCode와 매칭하여 사용자 찾기
- */
-async function findInChargeUserIdByEKGRP(EKGRP: string | null): Promise<number | null> {
- try {
- debugLog('담당자 찾기 시작', { EKGRP });
-
- if (!EKGRP) {
- debugError('EKGRP가 null 또는 undefined', { EKGRP });
- return null;
- }
-
- // users 테이블에서 userCode로 직접 조회
- const userResult = await db
- .select({ id: users.id })
- .from(users)
- .where(eq(users.userCode, EKGRP))
- .limit(1);
-
- if (userResult.length === 0) {
- debugError('EKGRP에 해당하는 사용자를 찾을 수 없음', { EKGRP });
- return null;
- }
-
- const userId = userResult[0].id;
- debugSuccess('담당자 찾음', { EKGRP, userId });
- return userId;
- } catch (error) {
- debugError('담당자 찾기 중 오류 발생', { EKGRP, error });
- return null;
- }
-}
/**
* RFQ 코드 생성 함수 (배치 처리용)
@@ -125,7 +103,7 @@ async function generateRfqCodes(eccHeaders: ECCBidHeader[]): Promise<Map<string,
for (const header of headers) {
const seqString = nextSeq.toString().padStart(5, '0');
const rfqCode = `RFQ${ekgrp}${seqString}`;
- rfqCodeMap.set(header.ANFNR, rfqCode);
+ rfqCodeMap.set(header.ANFNR || '', rfqCode);
nextSeq++; // 다음 시퀀스로 증가
}
}
@@ -143,77 +121,13 @@ async function generateRfqCodes(eccHeaders: ECCBidHeader[]): Promise<Map<string,
eccHeaders.forEach((header, index) => {
const ekgrp = header.EKGRP || 'UNKNOWN';
const seqString = (index + 1).toString().padStart(5, '0');
- fallbackMap.set(header.ANFNR, `RFQ${ekgrp}${seqString}`);
+ fallbackMap.set(header.ANFNR || '', `RFQ${ekgrp}${seqString}`);
});
return fallbackMap;
}
}
-/**
- * 프로젝트 ID 찾기 함수
- * 첫번째 PR Item의 PSPID와 projects.code 매칭
- */
-async function findProjectIdByPSPID(PSPID: string | null): Promise<number | null> {
- try {
- debugLog('프로젝트 ID 찾기 시작', { PSPID });
-
- if (!PSPID) {
- debugError('PSPID가 null 또는 undefined', { PSPID });
- return null;
- }
- const projectResult = await db
- .select({ id: projects.id })
- .from(projects)
- .where(eq(projects.code, PSPID))
- .limit(1);
-
- if (projectResult.length === 0) {
- debugError('PSPID에 해당하는 프로젝트를 찾을 수 없음', { PSPID });
- return null;
- }
-
- const projectId = projectResult[0].id;
- debugSuccess('프로젝트 ID 찾음', { PSPID, projectId });
- return projectId;
- } catch (error) {
- debugError('프로젝트 ID 찾기 중 오류 발생', { PSPID, error });
- return null;
- }
-}
-
-/**
- * 자재명 조회 함수
- * MATNR을 기반으로 MDG 테이블에서 ZZNAME 조회
- */
-async function findMaterialNameByMATNR(MATNR: string | null): Promise<string | null> {
- try {
- debugLog('자재명 조회 시작', { MATNR });
-
- if (!MATNR) {
- debugError('MATNR이 null 또는 undefined', { MATNR });
- return null;
- }
-
- const materialResult = await db
- .select({ ZZNAME: MATERIAL_MASTER_PART_MATL.ZZNAME })
- .from(MATERIAL_MASTER_PART_MATL)
- .where(eq(MATERIAL_MASTER_PART_MATL.MATNR, MATNR))
- .limit(1);
-
- if (materialResult.length === 0) {
- debugError('MATNR에 해당하는 자재를 찾을 수 없음', { MATNR });
- return null;
- }
-
- const materialName = materialResult[0].ZZNAME;
- debugSuccess('자재명 조회 완료', { MATNR, materialName });
- return materialName;
- } catch (error) {
- debugError('자재명 조회 중 오류 발생', { MATNR, error });
- return null;
- }
-}
// *****************************mapping functions*********************************
@@ -232,32 +146,11 @@ export async function mapECCRfqHeaderToRfqLast(
const firstItem = eccItems.find(item => item.ANFNR === eccHeader.ANFNR);
// 날짜 파싱 (실패시 현재 Date 들어감)
- let interfacedAt: Date = new Date();
-
- if (eccHeader.ZRFQ_TRS_DT != null && eccHeader.ZRFQ_TRS_TM != null) {
- try {
- // SAP 날짜 형식 (YYYYMMDD) 파싱
- const dateStr = eccHeader.ZRFQ_TRS_DT;
- if (dateStr.length === 8) {
- const year = parseInt(dateStr.substring(0, 4));
- const month = parseInt(dateStr.substring(4, 6)) - 1; // 0-based
- const day = parseInt(dateStr.substring(6, 8));
- const hour = parseInt(eccHeader.ZRFQ_TRS_TM.substring(0, 2));
- const minute = parseInt(eccHeader.ZRFQ_TRS_TM.substring(2, 4));
- const second = parseInt(eccHeader.ZRFQ_TRS_TM.substring(4, 6));
- interfacedAt = new Date(year, month, day, hour, minute, second);
- }
- } catch (error) {
- debugError('날짜 파싱 오류', {
- date: eccHeader.ZRFQ_TRS_DT,
- time: eccHeader.ZRFQ_TRS_TM,
- error,
- });
- }
- }
+ const interfacedAt = parseSAPDateTime(eccHeader.ZRFQ_TRS_DT || null, eccHeader.ZRFQ_TRS_TM || null);
// 담당자 찾기
- const inChargeUserId = await findInChargeUserIdByEKGRP(eccHeader.EKGRP || null);
+ const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null);
+ const inChargeUserId = inChargeUserInfo?.userId || null;
// 첫번째 PR Item 기반으로 projectId, itemCode, itemName 설정
let projectId: number | null = null;
@@ -267,7 +160,8 @@ export async function mapECCRfqHeaderToRfqLast(
if (firstItem) {
// projectId: 첫번째 PR Item의 PSPID와 projects.code 매칭
- projectId = await findProjectIdByPSPID(firstItem.PSPID || null);
+ const projectInfo = await findProjectInfoByPSPID(firstItem.PSPID || null);
+ projectId = projectInfo?.id || null;
// itemCode: 첫번째 PR Item의 MATKL
itemCode = firstItem.MATKL || null;
@@ -332,17 +226,8 @@ export function mapECCRfqItemToRfqPrItem(
// 날짜 파싱
let deliveryDate: Date | null = null;
if (eccItem.LFDAT) {
- try {
- const dateStr = eccItem.LFDAT;
- if (dateStr.length === 8) {
- const year = parseInt(dateStr.substring(0, 4));
- const month = parseInt(dateStr.substring(4, 6)) - 1;
- const day = parseInt(dateStr.substring(6, 8));
- deliveryDate = new Date(year, month, day);
- }
- } catch (error) {
- debugError('아이템 날짜 파싱 오류', { date: eccItem.LFDAT, error });
- }
+ const dateStr = parseSAPDateTime(eccItem.LFDAT, '000000');
+ deliveryDate = dateStr;
}
const mappedData: RfqPrItemData = {
@@ -434,14 +319,14 @@ export async function mapAndSaveECCRfqDataToRfqLast(
set: {
updatedAt: new Date(),
// ANFNR이 같으면 기존 데이터를 업데이트
- projectId: sql`EXCLUDED.project_id`,
- series: sql`EXCLUDED.series`,
- itemCode: sql`EXCLUDED.item_code`,
- itemName: sql`EXCLUDED.item_name`,
- picCode: sql`EXCLUDED.pic_code`,
- pic: sql`EXCLUDED.pic`,
- prNumber: sql`EXCLUDED.pr_number`,
- prIssueDate: sql`EXCLUDED.pr_issue_date`,
+ projectId: sql`EXCLUDED."project_id"`,
+ series: sql`EXCLUDED."series"`,
+ itemCode: sql`EXCLUDED."item_code"`,
+ itemName: sql`EXCLUDED."item_name"`,
+ picCode: sql`EXCLUDED."pic_code"`,
+ pic: sql`EXCLUDED."pic"`,
+ prNumber: sql`EXCLUDED."pr_number"`,
+ prIssueDate: sql`EXCLUDED."pr_issue_date"`,
}
})
.returning({ id: rfqsLast.id, rfqCode: rfqsLast.rfqCode });