summaryrefslogtreecommitdiff
path: root/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
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/ecc/mapper/bidding-and-pr-mapper.ts
parentf93493f68c9f368e10f1c3379f1c1384068e3b14 (diff)
(김준회) 입찰 인터페이스 처리, 자재그룹명 매핑 수정, 자재그룹 뷰 수정, 부서별 도메인 할당시 동기화 처리, 도메인 부서 할당 다이얼로그 부서목록 스크롤 처리, 삼성중공업 사용자 global search 개선
Diffstat (limited to 'lib/soap/ecc/mapper/bidding-and-pr-mapper.ts')
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts499
1 files changed, 499 insertions, 0 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