"use server"; import { GetVendorPoolSchema } from "./validations"; import { VendorPool } from "./types"; import db from "@/db/db"; import { vendorPool } from "@/db/schema/avl/vendor-pool"; import { eq, and, or, ilike, count, desc, sql, inArray } from "drizzle-orm"; import { debugError } from "@/lib/debug-utils"; import { revalidateTag, unstable_cache } from "next/cache"; import type { VendorPoolItem } from "./table/vendor-pool-table-columns"; import { MATERIAL_GROUP_MASTER } from "@/db/schema/MDG/mdg"; import { vendors } from "@/db/schema/vendors"; /** * Vendor Pool 목록 조회 * vendor_pool 테이블에서 실제 데이터를 조회합니다. */ const _getVendorPools = async (input: GetVendorPoolSchema) => { try { const offset = (input.page - 1) * input.perPage; // debugLog('Vendor Pool 목록 조회 시작', { input, offset }); // 검색 조건 구성 const whereConditions: any[] = []; // 검색어 기반 필터링 if (input.search) { const searchTerm = `%${input.search}%`; whereConditions.push( or( ilike(vendorPool.constructionSector, searchTerm), ilike(vendorPool.discipline, searchTerm), ilike(vendorPool.vendorName, searchTerm), ilike(vendorPool.materialGroupCode, searchTerm), ilike(vendorPool.materialGroupName, searchTerm), ilike(vendorPool.avlVendorName, searchTerm), ilike(vendorPool.similarVendorName, searchTerm) ) ); } // Advanced filters 처리 (DataTableFilterList에서 생성된 필터들) if (input.filters && input.filters.length > 0) { const filterConditions: any[] = []; for (const filter of input.filters) { let condition: any = null; switch (filter.id) { case 'constructionSector': if (filter.operator === 'eq') { condition = eq(vendorPool.constructionSector, filter.value as string); } else if (filter.operator === 'iLike') { condition = ilike(vendorPool.constructionSector, `%${filter.value}%`); } break; case 'htDivision': if (filter.operator === 'eq') { condition = eq(vendorPool.htDivision, filter.value as string); } else if (filter.operator === 'iLike') { condition = ilike(vendorPool.htDivision, `%${filter.value}%`); } break; case 'discipline': if (filter.operator === 'iLike') { condition = ilike(vendorPool.discipline, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.discipline, filter.value as string); } break; case 'materialGroupCode': if (filter.operator === 'iLike') { condition = ilike(vendorPool.materialGroupCode, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.materialGroupCode, filter.value as string); } break; case 'materialGroupName': if (filter.operator === 'iLike') { condition = ilike(vendorPool.materialGroupName, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.materialGroupName, filter.value as string); } break; case 'vendorCode': if (filter.operator === 'iLike') { condition = ilike(vendorPool.vendorCode, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.vendorCode, filter.value as string); } break; case 'vendorName': if (filter.operator === 'iLike') { condition = ilike(vendorPool.vendorName, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.vendorName, filter.value as string); } break; case 'faStatus': if (filter.operator === 'iLike') { condition = ilike(vendorPool.faStatus, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.faStatus, filter.value as string); } break; case 'tier': if (filter.operator === 'iLike') { condition = ilike(vendorPool.tier, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.tier, filter.value as string); } break; case 'headquarterLocation': if (filter.operator === 'iLike') { condition = ilike(vendorPool.headquarterLocation, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.headquarterLocation, filter.value as string); } break; case 'manufacturingLocation': if (filter.operator === 'iLike') { condition = ilike(vendorPool.manufacturingLocation, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.manufacturingLocation, filter.value as string); } break; case 'avlVendorName': if (filter.operator === 'iLike') { condition = ilike(vendorPool.avlVendorName, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.avlVendorName, filter.value as string); } break; case 'registrant': if (filter.operator === 'iLike') { condition = ilike(vendorPool.registrant, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.registrant, filter.value as string); } break; case 'lastModifier': if (filter.operator === 'iLike') { condition = ilike(vendorPool.lastModifier, `%${filter.value}%`); } else if (filter.operator === 'eq') { condition = eq(vendorPool.lastModifier, filter.value as string); } break; case 'isBlacklist': if (filter.operator === 'eq') { condition = eq(vendorPool.isBlacklist, filter.value === 'true'); } break; case 'isBcc': if (filter.operator === 'eq') { condition = eq(vendorPool.isBcc, filter.value === 'true'); } break; case 'registrationDate': case 'lastModifiedDate': // 날짜 필터 처리 (단순화된 버전) if (filter.operator === 'eq' && filter.value) { const dateValue = new Date(filter.value as string); if (filter.id === 'registrationDate') { condition = eq(vendorPool.registrationDate, dateValue); } else { condition = eq(vendorPool.lastModifiedDate, dateValue); } } break; } if (condition) { filterConditions.push(condition); } } // 필터 조건들을 AND 또는 OR로 결합 if (filterConditions.length > 0) { if (input.joinOperator === 'or') { whereConditions.push(or(...filterConditions)); } else { whereConditions.push(and(...filterConditions)); } } } // 기존 필터 조건 추가 (하위 호환성 유지) if (input.constructionSector) { whereConditions.push(eq(vendorPool.constructionSector, input.constructionSector)); } if (input.htDivision) { whereConditions.push(eq(vendorPool.htDivision, input.htDivision)); } if (input.discipline) { whereConditions.push(ilike(vendorPool.discipline, `%${input.discipline}%`)); } if (input.equipBulkDivision) { whereConditions.push(eq(vendorPool.equipBulkDivision, input.equipBulkDivision)); } if (input.materialGroupCode) { whereConditions.push(ilike(vendorPool.materialGroupCode, `%${input.materialGroupCode}%`)); } if (input.materialGroupName) { whereConditions.push(ilike(vendorPool.materialGroupName, `%${input.materialGroupName}%`)); } if (input.vendorCode) { whereConditions.push(ilike(vendorPool.vendorCode, `%${input.vendorCode}%`)); } if (input.vendorName) { whereConditions.push(ilike(vendorPool.vendorName, `%${input.vendorName}%`)); } if (input.faStatus) { whereConditions.push(ilike(vendorPool.faStatus, `%${input.faStatus}%`)); } if (input.tier) { whereConditions.push(ilike(vendorPool.tier, `%${input.tier}%`)); } if (input.isBlacklist === "true") { whereConditions.push(eq(vendorPool.isBlacklist, true)); } else if (input.isBlacklist === "false") { whereConditions.push(eq(vendorPool.isBlacklist, false)); } if (input.isBcc === "true") { whereConditions.push(eq(vendorPool.isBcc, true)); } else if (input.isBcc === "false") { whereConditions.push(eq(vendorPool.isBcc, false)); } // 정렬 조건 구성 const orderByConditions: any[] = []; input.sort.forEach((sortItem) => { const column = sortItem.id as keyof typeof vendorPool; // id 컬럼의 경우 특별 처리 (No. 컬럼 정렬용) if (column === 'id') { if (sortItem.desc) { orderByConditions.push(sql`${vendorPool.id} desc`); } else { orderByConditions.push(sql`${vendorPool.id} asc`); } } else if (column && vendorPool[column]) { if (sortItem.desc) { orderByConditions.push(sql`${vendorPool[column]} desc`); } else { orderByConditions.push(sql`${vendorPool[column]} asc`); } } }); // 기본 정렬 (등재일 내림차순) if (orderByConditions.length === 0) { orderByConditions.push(desc(vendorPool.registrationDate)); } // 총 개수 조회 const totalCount = await db .select({ count: count() }) .from(vendorPool) .where(and(...whereConditions)); // 데이터 조회 const data = await db .select() .from(vendorPool) .where(and(...whereConditions)) .orderBy(...orderByConditions) .limit(input.perPage) .offset(offset); // 데이터 변환 (timestamp -> string) const transformedData = data.map((item, index) => ({ ...item, no: offset + index + 1, selected: false, registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '', lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '', // string 필드들의 null 처리 discipline: item.discipline || '', materialGroupCode: item.materialGroupCode || '', materialGroupName: item.materialGroupName || '', similarMaterialNamePurchase: item.similarMaterialNamePurchase || '', vendorCode: item.vendorCode || '', vendorName: item.vendorName || '', taxId: item.taxId || '', faStatus: item.faStatus || '', tier: item.tier || '', headquarterLocation: item.headquarterLocation || '', manufacturingLocation: item.manufacturingLocation || '', avlVendorName: item.avlVendorName || '', similarVendorName: item.similarVendorName || '', purchaseOpinion: item.purchaseOpinion || '', recentQuoteDate: item.recentQuoteDate || '', recentQuoteNumber: item.recentQuoteNumber || '', recentOrderDate: item.recentOrderDate || '', recentOrderNumber: item.recentOrderNumber || '', registrant: item.registrant || '', lastModifier: item.lastModifier || '', // boolean 필드들을 적절히 처리 faTarget: item.faTarget ?? false, isBlacklist: item.isBlacklist ?? false, isBcc: item.isBcc ?? false, })); const pageCount = Math.ceil(totalCount[0].count / input.perPage); // debugSuccess('Vendor Pool 목록 조회 완료', { recordCount: transformedData.length, pageCount }); return { data: transformedData, pageCount }; } catch (err) { debugError('Vendor Pool 목록 조회 실패', { error: err, input }); console.error("Error in getVendorPools:", err); return { data: [], pageCount: 0 }; } }; // 캐시된 버전 export - 동일한 입력에 대해 캐시 사용 export const getVendorPools = unstable_cache( _getVendorPools, ['vendor-pool-list'], { tags: ['vendor-pool-list'], revalidate: 300, // 5분 캐시 } ); /** * Vendor Pool 전체 데이터 조회 (페이지네이션 없음) * 클라이언트 사이드 필터링/정렬을 위한 전체 데이터 로드 */ export async function getAllVendorPools(): Promise { try { // 전체 데이터 조회 (limit 없음) const data = await db .select() .from(vendorPool) .orderBy(desc(vendorPool.registrationDate)); // 데이터 변환 (timestamp -> string) const transformedData = data.map((item, index) => ({ ...item, no: index + 1, selected: false, registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '', lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '', // string 필드들의 null 처리 discipline: item.discipline || '', materialGroupCode: item.materialGroupCode || '', materialGroupName: item.materialGroupName || '', similarMaterialNamePurchase: item.similarMaterialNamePurchase || '', vendorCode: item.vendorCode || '', vendorName: item.vendorName || '', taxId: item.taxId || '', faStatus: item.faStatus || '', tier: item.tier || '', headquarterLocation: item.headquarterLocation || '', manufacturingLocation: item.manufacturingLocation || '', avlVendorName: item.avlVendorName || '', similarVendorName: item.similarVendorName || '', purchaseOpinion: item.purchaseOpinion || '', recentQuoteDate: item.recentQuoteDate || '', recentQuoteNumber: item.recentQuoteNumber || '', recentOrderDate: item.recentOrderDate || '', recentOrderNumber: item.recentOrderNumber || '', registrant: item.registrant || '', lastModifier: item.lastModifier || '', // boolean 필드들을 적절히 처리 faTarget: item.faTarget ?? false, isBlacklist: item.isBlacklist ?? false, isBcc: item.isBcc ?? false, })); return transformedData; } catch (err) { console.error("Error in getAllVendorPools:", err); return []; } } /** * Vendor Pool 상세 정보 조회 */ export async function getVendorPoolById(id: number): Promise { try { const data = await db .select() .from(vendorPool) .where(eq(vendorPool.id, id)) .limit(1); if (data.length === 0) { return null; } const item = data[0]; // 데이터 변환 (timestamp -> string) const transformedData: VendorPool = { ...item, selected: false, registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '', lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '', // string 필드들의 null 처리 discipline: item.discipline || '', materialGroupCode: item.materialGroupCode || '', materialGroupName: item.materialGroupName || '', similarMaterialNamePurchase: item.similarMaterialNamePurchase || '', vendorCode: item.vendorCode || '', vendorName: item.vendorName || '', taxId: item.taxId || '', faStatus: item.faStatus || '', tier: item.tier || '', headquarterLocation: item.headquarterLocation || '', manufacturingLocation: item.manufacturingLocation || '', avlVendorName: item.avlVendorName || '', similarVendorName: item.similarVendorName || '', purchaseOpinion: item.purchaseOpinion || '', recentQuoteDate: item.recentQuoteDate || '', recentQuoteNumber: item.recentQuoteNumber || '', recentOrderDate: item.recentOrderDate || '', recentOrderNumber: item.recentOrderNumber || '', registrant: item.registrant || '', lastModifier: item.lastModifier || '', // boolean 필드들을 적절히 처리 faTarget: item.faTarget ?? false, isBlacklist: item.isBlacklist ?? false, isBcc: item.isBcc ?? false, }; return transformedData; } catch (err) { console.error("Error in getVendorPoolById:", err); return null; } } /** * Vendor Pool 액션 처리 * 신규등록, 일괄입력, 저장 등의 액션을 처리 */ export async function handleVendorPoolAction( action: string, data?: any ): Promise<{ success: boolean; message: string; data?: any }> { try { switch (action) { case "new-registration": // 신규 등록은 createVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 return { success: true, message: "신규 등록 모달이 열렸습니다." }; case "bulk-import": // TODO: 파일 업로드 및 일괄 데이터 처리 로직 구현 필요 // 현재는 임시 구현 - 실제로는 파일 파싱 및 배치 삽입 로직이 필요 if (!data?.file) { return { success: false, message: "업로드할 파일이 없습니다." }; } console.log("일괄 입력 처리:", data.file); // 실제 구현 시: 파일 파싱 -> 데이터 검증 -> 배치 삽입 return { success: true, message: "일괄 입력 처리가 시작되었습니다." }; case "fa-detail": // FA 상세 정보 조회 - 실제로는 별도의 FA 조회 로직이 필요할 수 있음 if (!data?.id) { return { success: false, message: "FA 대상 ID가 없습니다." }; } console.log("FA 상세 조회:", data.id); return { success: true, message: "FA 상세 정보가 조회되었습니다.", data: { id: data.id } }; case "save": // 변경사항 저장 - 실제로는 변경된 데이터들을 배치 업데이트하는 로직이 필요 console.log("변경사항 저장:", data); // TODO: 변경된 항목들 검증 및 저장 로직 구현 return { success: true, message: "변경사항이 저장되었습니다." }; case "fixed-values": // 고정값 설정 - 실제로는 고정값 관리 모달과 설정 로직이 필요 console.log("고정값 설정:", data); // TODO: 고정값 설정 모달 및 저장 로직 구현 return { success: true, message: "고정값 설정이 완료되었습니다." }; case "edit": // 수정은 updateVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 if (!data?.id) { return { success: false, message: "수정할 항목 ID가 없습니다." }; } return { success: true, message: "수정 모달이 열렸습니다.", data: { id: data.id } }; case "delete": // 삭제는 deleteVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 if (!data?.id) { return { success: false, message: "삭제할 항목 ID가 없습니다." }; } return { success: true, message: "항목이 삭제되었습니다.", data: { id: data.id } }; case "view-detail": // 상세 조회는 getVendorPoolById 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 if (!data?.id) { return { success: false, message: "조회할 항목 ID가 없습니다." }; } return { success: true, message: "상세 정보가 조회되었습니다.", data: { id: data.id } }; default: return { success: false, message: `알 수 없는 액션입니다: ${action}` }; } } catch (err) { console.error("Error in handleVendorPoolAction:", err); return { success: false, message: "액션 처리 중 오류가 발생했습니다." }; } } /** * Vendor Pool 생성 */ export async function createVendorPool(data: Omit): Promise { try { // debugLog('Vendor Pool 생성 시작', { inputData: data }); // debugLog('데이터 검증 시작', { data, requiredFields: ['constructionSector', 'htDivision', 'designCategory', 'vendorName'] }); const currentTimestamp = new Date(); // 데이터베이스에 삽입할 데이터 준비 const insertData = { // 기본 정보 constructionSector: data.constructionSector, htDivision: data.htDivision, // 설계 정보 discipline: data.discipline, equipBulkDivision: data.equipBulkDivision, // 자재그룹 정보 materialGroupCode: data.materialGroupCode, materialGroupName: data.materialGroupName, // 자재 관련 정보 similarMaterialNamePurchase: data.similarMaterialNamePurchase, // 협력업체 정보 vendorCode: data.vendorCode, vendorName: data.vendorName, taxId: data.taxId, // 사업 및 인증 정보 faTarget: data.faTarget ?? false, faStatus: data.faStatus, tier: data.tier, // 위치 정보 headquarterLocation: data.headquarterLocation, manufacturingLocation: data.manufacturingLocation, // AVL 관련 정보 avlVendorName: data.avlVendorName, similarVendorName: data.similarVendorName, // 상태 정보 isBlacklist: data.isBlacklist ?? false, isBcc: data.isBcc ?? false, purchaseOpinion: data.purchaseOpinion, // 업체 실적 현황 recentQuoteDate: data.recentQuoteDate, recentQuoteNumber: data.recentQuoteNumber, recentOrderDate: data.recentOrderDate, recentOrderNumber: data.recentOrderNumber, // 업데이트 히스토리 registrationDate: currentTimestamp, registrant: data.registrant || 'system', lastModifiedDate: currentTimestamp, lastModifier: data.lastModifier || 'system', }; // debugLog('DB INSERT 시작', { table: 'vendor_pool', data: insertData }); // 데이터베이스에 삽입 const result = await db .insert(vendorPool) .values(insertData) .returning(); if (result.length === 0) { // debugError('DB 삽입 실패: 결과가 없음', { insertData }); throw new Error("Failed to create vendor pool"); } // debugSuccess('DB INSERT 완료', { table: 'vendor_pool', result: result[0] }); const createdItem = result[0]; // 생성된 데이터를 VendorPool 타입으로 변환 const transformedData: VendorPool = { ...createdItem, selected: false, registrationDate: createdItem.registrationDate ? createdItem.registrationDate.toISOString().split('T')[0] : '', lastModifiedDate: createdItem.lastModifiedDate ? createdItem.lastModifiedDate.toISOString().split('T')[0] : '', // string 필드들의 null 처리 discipline: createdItem.discipline || '', materialGroupCode: createdItem.materialGroupCode || '', materialGroupName: createdItem.materialGroupName || '', similarMaterialNamePurchase: createdItem.similarMaterialNamePurchase || '', vendorCode: createdItem.vendorCode || '', vendorName: createdItem.vendorName || '', taxId: createdItem.taxId || '', faStatus: createdItem.faStatus || '', tier: createdItem.tier || '', headquarterLocation: createdItem.headquarterLocation || '', manufacturingLocation: createdItem.manufacturingLocation || '', avlVendorName: createdItem.avlVendorName || '', similarVendorName: createdItem.similarVendorName || '', purchaseOpinion: createdItem.purchaseOpinion || '', recentQuoteDate: createdItem.recentQuoteDate || '', recentQuoteNumber: createdItem.recentQuoteNumber || '', recentOrderDate: createdItem.recentOrderDate || '', recentOrderNumber: createdItem.recentOrderNumber || '', registrant: createdItem.registrant || '', lastModifier: createdItem.lastModifier || '', // boolean 필드들을 적절히 처리 faTarget: createdItem.faTarget ?? false, isBlacklist: createdItem.isBlacklist ?? false, isBcc: createdItem.isBcc ?? false, }; // debugSuccess('Vendor Pool 생성 완료', { result: transformedData }); // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 revalidateTag('vendor-pool-list'); revalidateTag('vendor-pool-stats'); // debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); return transformedData; } catch (err) { // debugError('Vendor Pool 생성 실패', { error: err, inputData: data }); console.error("Error in createVendorPool:", err); // Unique 제약 조건 위반 감지 const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes('unique_vendor_pool_combination') || errorMessage.includes('duplicate key value') || errorMessage.includes('violates unique constraint')) { // debugError('Unique 제약 조건 위반 감지', { // constructionSector: data.constructionSector, // htDivision: data.htDivision, // materialGroupCode: data.materialGroupCode, // vendorName: data.vendorName, // error: err // }); // Unique 제약 위반의 경우 특별한 에러 객체를 throw throw new Error('DUPLICATE_VENDOR_POOL'); } return null; } } /** * Vendor Pool 업데이트 */ export async function updateVendorPool(id: number, data: Partial): Promise { try { // debugLog('Vendor Pool 업데이트 시작', { id, updateData: data }); const currentTimestamp = new Date(); // 업데이트할 데이터 준비 (id, registrationDate, registrant는 제외) const updateData: any = {}; // 기본 정보 if (data.constructionSector !== undefined) updateData.constructionSector = data.constructionSector; if (data.htDivision !== undefined) updateData.htDivision = data.htDivision; // 설계 정보 if (data.discipline !== undefined) updateData.discipline = data.discipline; if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision; // 자재그룹 정보 if (data.materialGroupCode !== undefined) updateData.materialGroupCode = data.materialGroupCode; if (data.materialGroupName !== undefined) updateData.materialGroupName = data.materialGroupName; // 자재 관련 정보 if (data.similarMaterialNamePurchase !== undefined) updateData.similarMaterialNamePurchase = data.similarMaterialNamePurchase; // 협력업체 정보 if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode; if (data.vendorName !== undefined) updateData.vendorName = data.vendorName; if (data.taxId !== undefined) updateData.taxId = data.taxId; // 사업 및 인증 정보 if (data.faTarget !== undefined) updateData.faTarget = data.faTarget; if (data.faStatus !== undefined) updateData.faStatus = data.faStatus; if (data.tier !== undefined) updateData.tier = data.tier; // 위치 정보 if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation; if (data.manufacturingLocation !== undefined) updateData.manufacturingLocation = data.manufacturingLocation; // AVL 관련 정보 if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName; if (data.similarVendorName !== undefined) updateData.similarVendorName = data.similarVendorName; // 상태 정보 if (data.isBlacklist !== undefined) updateData.isBlacklist = data.isBlacklist; if (data.isBcc !== undefined) updateData.isBcc = data.isBcc; if (data.purchaseOpinion !== undefined) updateData.purchaseOpinion = data.purchaseOpinion; // 업체 실적 현황 if (data.recentQuoteDate !== undefined) updateData.recentQuoteDate = data.recentQuoteDate; if (data.recentQuoteNumber !== undefined) updateData.recentQuoteNumber = data.recentQuoteNumber; if (data.recentOrderDate !== undefined) updateData.recentOrderDate = data.recentOrderDate; if (data.recentOrderNumber !== undefined) updateData.recentOrderNumber = data.recentOrderNumber; // 업데이트 히스토리 updateData.lastModifiedDate = currentTimestamp; updateData.lastModifier = data.lastModifier || 'system'; // 업데이트할 데이터가 없는 경우 if (Object.keys(updateData).length === 2) { // lastModifiedDate, lastModifier만 있는 경우 // 기존 데이터를 반환 return await getVendorPoolById(id); } // 데이터베이스 업데이트 const result = await db .update(vendorPool) .set(updateData) .where(eq(vendorPool.id, id)) .returning(); if (result.length === 0) { throw new Error("Vendor pool not found or update failed"); } const updatedItem = result[0]; // 업데이트된 데이터를 VendorPool 타입으로 변환 const transformedData: VendorPool = { ...updatedItem, selected: false, registrationDate: updatedItem.registrationDate ? updatedItem.registrationDate.toISOString().split('T')[0] : '', lastModifiedDate: updatedItem.lastModifiedDate ? updatedItem.lastModifiedDate.toISOString().split('T')[0] : '', // string 필드들의 null 처리 discipline: updatedItem.discipline || '', materialGroupCode: updatedItem.materialGroupCode || '', materialGroupName: updatedItem.materialGroupName || '', similarMaterialNamePurchase: updatedItem.similarMaterialNamePurchase || '', vendorCode: updatedItem.vendorCode || '', vendorName: updatedItem.vendorName || '', taxId: updatedItem.taxId || '', faStatus: updatedItem.faStatus || '', tier: updatedItem.tier || '', headquarterLocation: updatedItem.headquarterLocation || '', manufacturingLocation: updatedItem.manufacturingLocation || '', avlVendorName: updatedItem.avlVendorName || '', similarVendorName: updatedItem.similarVendorName || '', purchaseOpinion: updatedItem.purchaseOpinion || '', recentQuoteDate: updatedItem.recentQuoteDate || '', recentQuoteNumber: updatedItem.recentQuoteNumber || '', recentOrderDate: updatedItem.recentOrderDate || '', recentOrderNumber: updatedItem.recentOrderNumber || '', registrant: updatedItem.registrant || '', lastModifier: updatedItem.lastModifier || 'system', // boolean 필드들을 적절히 처리 faTarget: updatedItem.faTarget ?? false, isBlacklist: updatedItem.isBlacklist ?? false, isBcc: updatedItem.isBcc ?? false, }; // debugSuccess('Vendor Pool 업데이트 완료', { id, result: transformedData }); // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 revalidateTag('vendor-pool-list'); revalidateTag('vendor-pool-stats'); // debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); return transformedData; } catch (err) { // debugError('Vendor Pool 업데이트 실패', { error: err, id, updateData: data }); console.error("Error in updateVendorPool:", err); // Unique 제약 조건 위반 감지 const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes('unique_vendor_pool_combination') || errorMessage.includes('duplicate key value') || errorMessage.includes('violates unique constraint')) { // debugError('Unique 제약 조건 위반 감지', { // id, // constructionSector: data.constructionSector, // htDivision: data.htDivision, // materialGroupCode: data.materialGroupCode, // vendorName: data.vendorName, // error: err // }); // Unique 제약 위반의 경우 특별한 에러 객체를 throw throw new Error('DUPLICATE_VENDOR_POOL'); } return null; } } /** * Vendor Pool 삭제 */ export async function deleteVendorPool(id: number): Promise { try { // debugLog('Vendor Pool 삭제 시작', { id }); // 데이터베이스에서 삭제 await db .delete(vendorPool) .where(eq(vendorPool.id, id)); // Drizzle에서는 delete의 반환값이 삭제된 행의 수를 나타냄 // result.rowsAffected 또는 다른 방식으로 확인 // 실제로는 affectedRows나 rowCount 등을 확인해야 하지만, // drizzle의 delete는 성공 시 빈 배열이나 특정 값을 반환할 수 있음 // 삭제가 성공했는지 확인하기 위해 다시 조회해보기 const checkDeleted = await db .select({ id: vendorPool.id }) .from(vendorPool) .where(eq(vendorPool.id, id)) .limit(1); // 조회 결과가 없으면 삭제 성공 const isDeleted = checkDeleted.length === 0; if (isDeleted) { // debugSuccess('Vendor Pool 삭제 완료', { id }); // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 revalidateTag('vendor-pool-list'); revalidateTag('vendor-pool-stats'); // debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); } else { // debugWarn('Vendor Pool 삭제 실패: 항목이 존재함', { id }); } return isDeleted; } catch (err) { // debugError('Vendor Pool 삭제 실패', { error: err, id }); console.error("Error in deleteVendorPool:", err); return false; } } export type ImportResultItem = { rowNumber: number; status: 'success' | 'error' | 'duplicate' | 'warning'; message: string; data?: any; } export type ImportResult = { totalRows: number; successCount: number; errorCount: number; duplicateCount: number; items: ImportResultItem[]; } // Boolean 값 파싱 (서버 사이드용 - 클라이언트 유틸과 유사하게 동작) function parseBooleanServer(value: any): boolean { if (typeof value === 'boolean') return value; const strValue = String(value).toLowerCase().trim(); return strValue === 'true' || strValue === '1' || strValue === 'yes' || strValue === 'o' || strValue === 'y' || strValue === '참'; } /** * Vendor Pool 일괄 입력 처리 (Bulk Import) * - 한 번에 여러 행을 입력받아 처리 * - Bulk Lookup으로 성능 최적화 */ export async function processBulkImport(rows: Record[], registrant: string): Promise { const result: ImportResult = { totalRows: rows.length, successCount: 0, errorCount: 0, duplicateCount: 0, items: [] }; if (rows.length === 0) { return result; } try { // 1. Lookup을 위한 고유 코드 추출 const materialGroupCodes = new Set(); const vendorCodes = new Set(); rows.forEach(row => { if (row.materialGroupCode) materialGroupCodes.add(String(row.materialGroupCode).trim()); if (row.vendorCode) vendorCodes.add(String(row.vendorCode).trim()); }); // 2. Bulk Fetch (DB 조회) const materialGroupMap = new Map(); if (materialGroupCodes.size > 0) { const materialGroups = await db .select({ code: MATERIAL_GROUP_MASTER.materialGroupCode, name: MATERIAL_GROUP_MASTER.materialGroupDescription }) .from(MATERIAL_GROUP_MASTER) .where(inArray(MATERIAL_GROUP_MASTER.materialGroupCode, Array.from(materialGroupCodes))); materialGroups.forEach(mg => { if (mg.code && mg.name) { materialGroupMap.set(mg.code.trim(), mg.name); } }); } const vendorMap = new Map(); if (vendorCodes.size > 0) { const vendorList = await db .select({ code: vendors.vendorCode, name: vendors.vendorName }) .from(vendors) .where(inArray(vendors.vendorCode, Array.from(vendorCodes))); vendorList.forEach(v => { if (v.code && v.name) { vendorMap.set(v.code.trim(), v.name); } }); } // 2.5. 중복 검사를 위한 기존 데이터 조회 const targetVendorNames = new Set(); rows.forEach(row => { let vName = row.vendorName; if (!vName && row.vendorCode) { // vendorMap에서 조회 vName = vendorMap.get(String(row.vendorCode).trim()); } if (vName) { targetVendorNames.add(String(vName).trim()); } }); const existingRecordsMap = new Map(); if (targetVendorNames.size > 0) { const existingRecords = await db .select({ id: vendorPool.id, constructionSector: vendorPool.constructionSector, htDivision: vendorPool.htDivision, discipline: vendorPool.discipline, materialGroupCode: vendorPool.materialGroupCode, vendorName: vendorPool.vendorName }) .from(vendorPool) .where(inArray(vendorPool.vendorName, Array.from(targetVendorNames))); existingRecords.forEach(rec => { // Key: constructionSector|htDivision|discipline|materialGroupCode|vendorName (trim 처리) const cs = rec.constructionSector.trim(); const ht = rec.htDivision.trim(); const d = rec.discipline ? rec.discipline.trim() : ''; const m = rec.materialGroupCode ? rec.materialGroupCode.trim() : ''; const v = rec.vendorName ? rec.vendorName.trim() : ''; const key = `${cs}|${ht}|${d}|${m}|${v}`; existingRecordsMap.set(key, rec.id); }); } // 3. 데이터 처리 및 검증 const validInsertRows: any[] = []; const validUpdateRows: { id: number; data: any; rowNumber: number }[] = []; const currentTimestamp = new Date(); for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowNumber = i + 1; const vendorPoolData: any = {}; // 기본 필드 매핑 및 타입 변환 const booleanFields = ['faTarget', 'isBlacklist', 'isBcc']; Object.keys(row).forEach(key => { const value = row[key]; if (booleanFields.includes(key)) { vendorPoolData[key] = parseBooleanServer(value); } else if (value === '' || value === undefined || value === null) { vendorPoolData[key] = null; } else { vendorPoolData[key] = String(value); } }); // Enrichment (자동완성) if (vendorPoolData.materialGroupCode && !vendorPoolData.materialGroupName) { const mappedName = materialGroupMap.get(String(vendorPoolData.materialGroupCode).trim()); if (mappedName) { vendorPoolData.materialGroupName = mappedName; } } if (vendorPoolData.vendorCode && !vendorPoolData.vendorName) { const mappedName = vendorMap.get(String(vendorPoolData.vendorCode).trim()); if (mappedName) { vendorPoolData.vendorName = mappedName; } } // 필수 필드 검증 (1차 검증) // 키 필드가 null이면 실패 처리 const keyFields = [ { key: 'constructionSector', label: '공사부문' }, { key: 'htDivision', label: 'H/T구분' }, { key: 'discipline', label: '설계공종' }, { key: 'materialGroupCode', label: '자재그룹코드' } ]; const missingKeyFields = keyFields .filter(field => !vendorPoolData[field.key]) .map(field => field.label); // vendorName은 vendorCode가 있으면 자동완성되므로, enrichment 후에 체크 if (!vendorPoolData.vendorName) { missingKeyFields.push('협력업체명'); } if (missingKeyFields.length > 0) { result.errorCount++; result.items.push({ rowNumber, status: 'error', message: `필수 키 필드 누락: ${missingKeyFields.join(', ')}`, data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, discipline: vendorPoolData.discipline, } }); continue; } // 데이터 형식 검증 const validationErrors: string[] = []; if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) { validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`); } if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) { validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`); } if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) { validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`); } if (validationErrors.length > 0) { result.errorCount++; result.items.push({ rowNumber, status: 'error', message: `검증 실패: ${validationErrors.join(', ')}`, data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, discipline: vendorPoolData.discipline, } }); continue; } // 메타데이터 추가 vendorPoolData.lastModifier = registrant; vendorPoolData.lastModifiedDate = currentTimestamp; // 기본값 처리 if (vendorPoolData.faTarget === undefined) vendorPoolData.faTarget = false; if (vendorPoolData.isBlacklist === undefined) vendorPoolData.isBlacklist = false; if (vendorPoolData.isBcc === undefined) vendorPoolData.isBcc = false; // 중복 검사 (2차 검증) // [공사부문, H/T, 설계공종, 자재그룹코드, 협력업체명] const checkConstructionSector = String(vendorPoolData.constructionSector).trim(); const checkHtDivision = String(vendorPoolData.htDivision).trim(); const checkDiscipline = String(vendorPoolData.discipline).trim(); const checkMaterialGroupCode = String(vendorPoolData.materialGroupCode).trim(); const checkVendorName = String(vendorPoolData.vendorName).trim(); const duplicateKey = `${checkConstructionSector}|${checkHtDivision}|${checkDiscipline}|${checkMaterialGroupCode}|${checkVendorName}`; if (existingRecordsMap.has(duplicateKey)) { const existingId = existingRecordsMap.get(duplicateKey)!; validUpdateRows.push({ id: existingId, data: vendorPoolData, rowNumber }); } else { // 신규 등록 (3차 검증 - Insert) vendorPoolData.registrant = registrant; vendorPoolData.registrationDate = currentTimestamp; validInsertRows.push({ rowNumber, data: vendorPoolData }); } } // 4. Bulk Execution // 4.1 Updates (Sequential to avoid deadlock, though simple updates usually fine) // 업데이트는 개별적으로 수행해야 함 (값들이 다를 수 있으므로) for (const updateItem of validUpdateRows) { try { await db.update(vendorPool) .set(updateItem.data) .where(eq(vendorPool.id, updateItem.id)); result.duplicateCount++; // 업데이트된 건수를 중복(업데이트) 카운트로 처리 } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); result.errorCount++; result.items.push({ rowNumber: updateItem.rowNumber, status: 'error', message: `데이터 업데이트 실패: ${errorMsg}`, data: { vendorName: updateItem.data.vendorName } }); } } // 4.2 Inserts (Batch) if (validInsertRows.length > 0) { // 500개씩 나누어 처리 (Batch Insert) const BATCH_SIZE = 500; for (let i = 0; i < validInsertRows.length; i += BATCH_SIZE) { const batch = validInsertRows.slice(i, i + BATCH_SIZE); const batchData = batch.map(item => item.data); try { await db.insert(vendorPool).values(batchData); result.successCount += batch.length; } catch (err) { // 배치 실패 시 개별 재시도 console.error("Batch insert error, falling back to individual insert:", err); // Fallback to individual insert for this batch to identify errors for (const item of batch) { try { await db.insert(vendorPool).values(item.data); result.successCount++; } catch (innerErr) { const innerErrorMsg = innerErr instanceof Error ? innerErr.message : String(innerErr); if (innerErrorMsg.includes('unique_vendor_pool_combination') || innerErrorMsg.includes('duplicate key value')) { // DB 레벨에서 중복 발생 시 (거의 발생 안해야 함, 위에서 체크했으므로) // 하지만 동시성 이슈 등으로 발생 가능 result.errorCount++; result.items.push({ rowNumber: item.rowNumber, status: 'error', message: `중복 데이터 발생 (동시성 이슈 가능성): ${innerErrorMsg}`, data: { vendorName: item.data.vendorName } }); } else { result.errorCount++; result.items.push({ rowNumber: item.rowNumber, status: 'error', message: `데이터 저장 실패: ${innerErrorMsg}`, data: { vendorName: item.data.vendorName, materialGroupName: item.data.materialGroupName, discipline: item.data.discipline, } }); } } } } } } // 캐시 무효화 if (result.successCount > 0 || result.duplicateCount > 0) { revalidateTag('vendor-pool-list'); revalidateTag('vendor-pool-stats'); } return result; } catch (error) { console.error("Process bulk import error:", error); throw error; } }