diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/vendors/service.ts | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib/vendors/service.ts')
| -rw-r--r-- | lib/vendors/service.ts | 731 |
1 files changed, 530 insertions, 201 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 8f095c0e..87a8336d 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,12 +2,13 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; +import { headers } from 'next/headers'; import { selectVendors, @@ -24,7 +25,10 @@ import { countVendorItems, insertVendorItem, countRfqHistory, - selectRfqHistory + selectRfqHistory, + selectVendorsWithTypes, + countVendorsWithTypes, + } from "./repository"; import type { @@ -51,7 +55,8 @@ import { items } from "@/db/schema/items"; import { users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { projects, vendorProjectPQs } from "@/db/schema"; +import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema"; +import { Hospital } from "lucide-react"; /* ----------------------------------------------------- @@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) { async () => { try { const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 + + // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ - table: vendors, + table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); - + // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(vendors.vendorName, s), - ilike(vendors.vendorCode, s), - ilike(vendors.email, s), - ilike(vendors.status, s) + ilike(vendorsWithTypesView.vendorName, s), + ilike(vendorsWithTypesView.vendorCode, s), + ilike(vendorsWithTypesView.email, s), + ilike(vendorsWithTypesView.status, s), + // 추가: 업체 유형 검색 + ilike(vendorsWithTypesView.vendorTypeName, s) ); } - + // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); - + // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName - ? ilike(vendors.vendorName, `%${input.vendorName}%`) + ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`) : undefined, - input.status ? ilike(vendors.status, input.status) : undefined, + input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined, input.country - ? ilike(vendors.country, `%${input.country}%`) + ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); - + // 실제 사용될 where const where = finalWhere; - + // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) - : [asc(vendors.createdAt)]; - + : [asc(vendorsWithTypesView.createdAt)]; + // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { - // 1) vendor 목록 조회 - const vendorsData = await selectVendors(tx, { + // 1) vendor 목록 조회 (view 사용) + const vendorsData = await selectVendorsWithTypes(tx, { where, orderBy, offset, limit: input.perPage, }); - + // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { @@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + return { ...vendor, hasAttachments: attachments.length > 0, @@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) { }; }) ); - + // 3) 전체 개수 - const total = await countVendors(tx, where); + const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); - + // 페이지 수 const pageCount = Math.ceil(total / input.perPage); - + return { data, pageCount }; } catch (err) { + console.error("Error fetching vendors:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } @@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) { )(); } - export async function getVendorStatusCounts() { return unstable_cache( async () => { @@ -252,34 +259,58 @@ async function storeVendorFiles( } } + +export async function getVendorTypes() { + unstable_noStore(); // Next.js server action caching prevention + + try { + const types = await db + .select({ + id: vendorTypes.id, + code: vendorTypes.code, + nameKo: vendorTypes.nameKo, + nameEn: vendorTypes.nameEn, + }) + .from(vendorTypes) + .orderBy(vendorTypes.nameKo); + + return { data: types, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + export type CreateVendorData = { vendorName: string + vendorTypeId: number vendorCode?: string + items?: string website?: string taxId: string address?: string email: string phone?: string - + representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string - + creditAgency?: string creditRating?: string cashFlowRating?: string corporateRegistrationNumber?: string - + country?: string status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" } +// Updated createVendor function with taxId duplicate check export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -292,17 +323,17 @@ export async function createVendor(params: { }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 - + try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params - + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); - + // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { return { @@ -310,7 +341,22 @@ export async function createVendor(params: { error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + + // taxId 중복 검사 추가 + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendorData.taxId)) + .limit(1); + + // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 + if (existingVendor.length > 0) { + return { + data: null, + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` + }; + } + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -323,36 +369,38 @@ export async function createVendor(params: { website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, - + vendorTypeId: vendorData.vendorTypeId, + items: vendorData.items || null, + // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, - + // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) - + // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } - + // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } - + // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } - + for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, @@ -364,7 +412,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -377,12 +425,26 @@ export async function createVendor(params: { /** 단건 업데이트 */ export async function modifyVendor( - input: UpdateVendorSchema & { id: string } + input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가 ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { - // 특정 ID 벤더를 업데이트 + // 1. 업데이트 전에 기존 벤더 정보를 가져옴 + const existingVendor = await tx.query.vendors.findFirst({ + where: eq(vendors.id, parseInt(input.id)), + columns: { + status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴 + }, + }); + + if (!existingVendor) { + throw new Error(`Vendor with ID ${input.id} not found`); + } + + const oldStatus = existingVendor.status; + + // 2. 벤더 정보 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, @@ -391,8 +453,32 @@ export async function modifyVendor( phone: input.phone, email: input.email, website: input.website, + creditAgency: input.creditAgency, + creditRating: input.creditRating, + cashFlowRating: input.cashFlowRating, status: input.status, }); + + // 3. 상태가 변경되었다면 로그 기록 + if (oldStatus !== input.status) { + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "status_change", + oldStatus, + newStatus: input.status, + comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`, + }); + } else if (input.comment) { + // 상태 변경이 없더라도 코멘트가 있으면 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "vendor_updated", + comment: input.comment, + }); + } + return res; }); @@ -414,7 +500,7 @@ export async function modifyVendors(input: { unstable_noStore(); try { const data = await db.transaction(async (tx) => { - // 여러 벤더 일괄 업데이트 + // 여러 협력업체 일괄 업데이트 const [updated] = await updateVendors(tx, input.ids, { // 예: 상태만 일괄 변경 status: input.status, @@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-contacts-${input.vendorId}`); return { data: null, error: null }; @@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-items-${input.vendorId}`); return { data: null, error: null }; @@ -885,98 +971,55 @@ interface CreateCompanyInput { /** - * 벤더 첨부파일 다운로드를 위한 서버 액션 - * @param vendorId 벤더 ID + * 협력업체 첨부파일 다운로드를 위한 서버 액션 + * @param vendorId 협력업체 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ -export async function downloadVendorAttachments(vendorId: number, fileId?: number) { +export async function downloadVendorAttachments(vendorId:number, fileId?:number) { try { - // 벤더 정보 조회 - const vendor = await db.query.vendors.findFirst({ - where: eq(vendors.id, vendorId) + // API 경로 생성 (단일 파일 또는 모든 파일) + const url = fileId + ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` + : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; + + // fetch 요청 (기본적으로 Blob으로 응답 받기) + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, }); - - if (!vendor) { - throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); - } - - // 첨부파일 조회 (특정 파일 또는 모든 파일) - const attachments = fileId - ? await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.id, fileId)) - : await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendorId)); - - if (!attachments.length) { - throw new Error('다운로드할 첨부파일이 없습니다.'); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } - - // 업로드 기본 경로 - const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'); - - // 단일 파일인 경우 직접 URL 반환 - if (attachments.length === 1) { - const attachment = attachments[0]; - const filePath = `/api/vendors/attachments/download?id=${attachment.id}`; - return { url: filePath, fileName: attachment.fileName }; + + // 파일명 가져오기 (Content-Disposition 헤더에서) + const contentDisposition = response.headers.get('content-disposition'); + let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } } - - // 다중 파일: 임시 ZIP 생성 후 URL 반환 - // 임시 디렉토리 생성 - const tempDir = path.join(process.cwd(), 'tmp'); - await fsPromises.mkdir(tempDir, { recursive: true }); - - // 고유 ID로 임시 ZIP 파일명 생성 - const tempId = randomUUID(); - const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`; - const zipFilePath = path.join(tempDir, zipFileName); - - // JSZip을 사용하여 ZIP 파일 생성 - const zip = new JSZip(); - - // 파일 읽기 및 추가 작업을 병렬로 처리 - await Promise.all( - attachments.map(async (attachment) => { - const filePath = path.join(basePath, attachment.filePath); - - try { - // 파일 존재 확인 (fsPromises.access 사용) - try { - await fsPromises.access(filePath, fs.constants.F_OK); - } catch (e) { - console.warn(`파일이 존재하지 않습니다: ${filePath}`); - return; // 파일이 없으면 건너뜀 - } - - // 파일 읽기 (fsPromises.readFile 사용) - const fileData = await fsPromises.readFile(filePath); - - // ZIP에 파일 추가 - zip.file(attachment.fileName, fileData); - } catch (error) { - console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); - // 오류가 있더라도 계속 진행 - } - }) - ); - - // ZIP 생성 및 저장 - const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } }); - await fsPromises.writeFile(zipFilePath, zipContent); - - // 임시 ZIP 파일에 접근할 수 있는 URL 생성 - const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`; - - return { - url: downloadUrl, - fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip` + + // Blob으로 응답 변환 + const blob = await response.blob(); + + // Blob URL 생성 + const blobUrl = window.URL.createObjectURL(blob); + + return { + url: blobUrl, + fileName, + blob }; } catch (error) { - console.error('첨부파일 다운로드 서버 액션 오류:', error); - throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.'); + console.error('Download API error:', error); + throw error; } } @@ -1016,13 +1059,22 @@ interface ApproveVendorsInput { /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ -export async function approveVendors(input: ApproveVendorsInput) { +export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { - // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) { }) ); - // 4. 각 벤더에게 이메일 발송 + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_REVIEW", + comment: "Vendor approved for review", + }); + }) + ); + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Admin Account Created"; - const loginUrl = "http://3.36.56.124:3000/en/login"; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/en/login`; await sendEmail({ to: vendor.email, @@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) { } } -export async function requestPQVendors(input: ApproveVendorsInput) { +export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { @@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) { } } - // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); } - - // 4. 각 벤더에게 이메일 발송 + + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_PQ", + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + : "PQ requested", + }); + }) + ); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 const subject = input.projectId @@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { : "[eVCP] You are invited to submit PQ"; // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) - const baseLoginUrl = "http://3.36.56.124:3000/en/login"; + const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; @@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await sendEmail({ to: vendor.email, subject, - template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + // template: "vendor-pq-status", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, @@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) { return { data: null, error: getErrorMessage(err) }; } } - interface SendVendorsInput { ids: number[]; } /** - * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션 + * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션 */ -export async function sendVendors(input: SendVendorsInput) { +export async function sendVendors(input: SendVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { - // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 + // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await db.query.vendors.findMany({ where: and( inArray(vendors.id, input.ids), @@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) { // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { - // 2-1. 벤더 연락처 정보 조회 + // 2-1. 협력업체 연락처 정보 조회 const contacts = await db.query.vendorContacts.findMany({ where: eq(vendorContacts.vendorId, vendor.id) }); - // 2-2. 벤더 가능 아이템 조회 + // 2-2. 협력업체 가능 아이템 조회 const possibleItems = await db.query.vendorPossibleItems.findMany({ where: eq(vendorPossibleItems.vendorId, vendor.id) }); - // 2-3. 벤더 첨부파일 조회 + // 2-3. 협력업체 첨부파일 조회 const attachments = await db.query.vendorAttachments.findMany({ where: eq(vendorAttachments.vendorId, vendor.id), columns: { @@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) { } }); - - // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) + // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { id: vendor.id, vendorName: vendor.vendorName, @@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) { throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); } - // 2-5. 벤더 코드 및 상태 업데이트 + // 2-5. 협력업체 코드 및 상태 업데이트 const vendorCode = responseData.vendorCode; const [updated] = await tx @@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) { .where(eq(vendors.id, vendor.id)) .returning(); - // 2-6. 벤더에게 알림 이메일 발송 + // 2-6. 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "status_change", + oldStatus: "APPROVED", + newStatus: "ACTIVE", + comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`, + }); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2-7. 벤더에게 알림 이메일 발송 if (vendor.email) { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Vendor Registration Completed"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - - const portalUrl = `${baseUrl}/en/partners`; + const portalUrl = `http://${host}/en/partners`; await sendEmail({ to: vendor.email, @@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) { message: "Successfully sent to ERP system", }); } catch (vendorError) { - // 개별 벤더 처리 오류 기록 + // 개별 협력업체 처리 오류 기록 results.push({ id: vendor.id, success: false, error: getErrorMessage(vendorError), }); + + // 에러가 발생해도 로그는 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "erp_send_failed", + comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`, + }); } } @@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) { } } - interface RequestInfoProps { ids: number[]; + userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함 } -export async function requestInfo({ ids }: RequestInfoProps) { +export async function requestInfo({ ids, userId }: RequestInfoProps) { try { - // 1. 벤더 정보 가져오기 - const vendorList = await db.query.vendors.findMany({ - where: inArray(vendors.id, ids), - }); - - if (!vendorList.length) { - return { error: "벤더 정보를 찾을 수 없습니다." }; - } + return await db.transaction(async (tx) => { + // 1. 협력업체 정보 가져오기 + const vendorList = await tx.query.vendors.findMany({ + where: inArray(vendors.id, ids), + }); - // 2. 각 벤더에게 이메일 보내기 - for (const vendor of vendorList) { - // 이메일이 없는 경우 스킵 - if (!vendor.email) continue; + if (!vendorList.length) { + return { error: "협력업체 정보를 찾을 수 없습니다." }; + } - // 벤더 정보 페이지 URL 생성 - const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 + for (const vendor of vendorList) { + // 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: userId, + action: "info_requested", + comment: "추가 정보 요청됨", + }); - // 벤더에게 이메일 보내기 - await sendEmail({ - to: vendor.email, - subject: "[EVCP] 추가 정보 요청 / Additional Information Request", - template: "vendor-additional-info", - context: { - vendorName: vendor.vendorName, - vendorInfoUrl: vendorInfoUrl, - language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 - }, - }); - } + // 이메일이 없는 경우 스킵 + if (!vendor.email) continue; + + // 협력업체 정보 페이지 URL 생성 + const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`; + + // 벤더에게 이메일 보내기 + await sendEmail({ + to: vendor.email, + subject: "[EVCP] 추가 정보 요청 / Additional Information Request", + template: "vendor-additional-info", + context: { + vendorName: vendor.vendorName, + vendorInfoUrl: vendorInfoUrl, + language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 + }, + }); + } - // 3. 성공적으로 처리됨 - return { success: true }; + // 3. 성공적으로 처리됨 + return { success: true }; + }); } catch (error) { - console.error("벤더 정보 요청 중 오류 발생:", error); - return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; + console.error("협력업체 정보 요청 중 오류 발생:", error); + return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; } } export async function getVendorDetailById(id: number) { try { - // View를 통해 벤더 정보 조회 + // View를 통해 협력업체 정보 조회 const vendor = await db .select() .from(vendorDetailView) @@ -1496,7 +1624,7 @@ export type ContactInfo = { } /** - * 벤더 정보를 업데이트하는 함수 + * 협력업체 정보를 업데이트하는 함수 */ export async function updateVendorInfo(params: { vendorData: UpdateVendorInfoData @@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: { // 트랜잭션으로 업데이트 수행 await db.transaction(async (tx) => { - // 1. 벤더 정보 업데이트 + // 1. 협력업체 정보 업데이트 await tx.update(vendors).set({ vendorName: vendorData.vendorName, address: vendorData.address || null, @@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: { return { data: { success: true, - message: '벤더 정보가 성공적으로 업데이트되었습니다.', + message: '협력업체 정보가 성공적으로 업데이트되었습니다.', vendorId: vendorData.id }, error: null @@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: { console.error("Vendor info update error:", error); return { data: null, error: getErrorMessage(error) } } +} + + + +export interface VendorsLogWithUser { + id: number + vendorCandidateId: number + userId: number + userName: string | null + userEmail: string | null + action: string + oldStatus: string | null + newStatus: string | null + comment: string | null + createdAt: Date +} + +export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> { + try { + const logs = await db + .select({ + id: vendorsLogs.id, + vendorCandidateId: vendorsLogs.vendorId, + userId: vendorsLogs.userId, + action: vendorsLogs.action, + oldStatus: vendorsLogs.oldStatus, + newStatus: vendorsLogs.newStatus, + comment: vendorsLogs.comment, + createdAt: vendorsLogs.createdAt, + + // 조인한 users 테이블 필드 + userName: users.name, + userEmail: users.email, + }) + .from(vendorsLogs) + .leftJoin(users, eq(vendorsLogs.userId, users.id)) + .where(eq(vendorsLogs.vendorId, vendorId)) + .orderBy(desc(vendorsLogs.createdAt)) + + return logs + } catch (error) { + console.error("Failed to fetch candidate logs with user info:", error) + throw error + } +} + + + +/** + * 엑셀 내보내기용 벤더 연락처 목록 조회 + * - 페이지네이션 없이 모든 연락처 반환 + */ +export async function exportVendorContacts(vendorId: number) { + try { + const contacts = await db + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); + + return contacts; + } catch (error) { + console.error("Failed to export vendor contacts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 아이템 목록 조회 + * - 페이지네이션 없이 모든 아이템 정보 반환 + */ +export async function exportVendorItems(vendorId: number) { + try { + const vendorItems = await db + .select({ + id: vendorItemsView.vendorItemId, + vendorId: vendorItemsView.vendorId, + itemName: vendorItemsView.itemName, + itemCode: vendorItemsView.itemCode, + description: vendorItemsView.description, + createdAt: vendorItemsView.createdAt, + updatedAt: vendorItemsView.updatedAt, + }) + .from(vendorItemsView) + .where(eq(vendorItemsView.vendorId, vendorId)) + .orderBy(vendorItemsView.itemName); + + return vendorItems; + } catch (error) { + console.error("Failed to export vendor items:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 RFQ 목록 조회 + * - 페이지네이션 없이 모든 RFQ 정보 반환 + */ +export async function exportVendorRFQs(vendorId: number) { + try { + const rfqs = await db + .select() + .from(vendorRfqView) + .where(eq(vendorRfqView.vendorId, vendorId)) + .orderBy(vendorRfqView.rfqVendorUpdated); + + return rfqs; + } catch (error) { + console.error("Failed to export vendor RFQs:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 계약 목록 조회 + * - 페이지네이션 없이 모든 계약 정보 반환 + */ +export async function exportVendorContracts(vendorId: number) { + try { + const contracts = await db + .select() + .from(contractsDetailView) + .where(eq(contractsDetailView.vendorId, vendorId)) + .orderBy(contractsDetailView.createdAt); + + return contracts; + } catch (error) { + console.error("Failed to export vendor contracts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 정보 조회 + * - 페이지네이션 없이 모든 벤더 정보 반환 + */ +export async function exportVendorDetails(vendorIds: number[]) { + try { + if (!vendorIds.length) return []; + + // 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + address: vendors.address, + country: vendors.country, + phone: vendors.phone, + email: vendors.email, + website: vendors.website, + status: vendors.status, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + }) + .from(vendors) + .where( + vendorIds.length === 1 + ? eq(vendors.id, vendorIds[0]) + : inArray(vendors.id, vendorIds) + ); + + // 벤더별 상세 정보를 포함하여 반환 + const vendorsWithDetails = await Promise.all( + vendorsData.map(async (vendor) => { + // 연락처 조회 + const contacts = await exportVendorContacts(vendor.id); + + // 아이템 조회 + const items = await exportVendorItems(vendor.id); + + // RFQ 조회 + const rfqs = await exportVendorRFQs(vendor.id); + + // 계약 조회 + const contracts = await exportVendorContracts(vendor.id); + + return { + ...vendor, + vendorContacts: contacts, + vendorItems: items, + vendorRfqs: rfqs, + vendorContracts: contracts, + }; + }) + ); + + return vendorsWithDetails; + } catch (error) { + console.error("Failed to export vendor details:", error); + return []; + } }
\ No newline at end of file |
