diff options
Diffstat (limited to 'lib/vendors/service.ts')
| -rw-r--r-- | lib/vendors/service.ts | 992 |
1 files changed, 628 insertions, 364 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 7c8df1a6..d91fbd03 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -5,11 +5,14 @@ import db from "@/db/db"; import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMateirals, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema"; import logger from '@/lib/logger'; import * as z from "zod" +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; 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, @@ -49,17 +52,17 @@ import type { import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; -import path from "path"; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items, materials } from "@/db/schema/items"; -import { roles, userRoles, users } from "@/db/schema/users"; +import { mfaTokens, roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema"; -import { deleteFile, saveFile } from "../file-stroage"; - - +import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorsLogs } from "@/db/schema"; +import { deleteFile, saveFile, saveBuffer } from "../file-stroage"; +import { basicContractTemplates } from "@/db/schema/basicContractDocumnet"; +import { basicContract } from "@/db/schema/basicContractDocumnet"; +import { headers } from 'next/headers'; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ @@ -74,14 +77,14 @@ export async function getVendors(input: GetVendorsSchema) { async () => { try { const offset = (input.page - 1) * input.perPage; - + // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); - + // 2) 글로벌 검색 let globalWhere; if (input.search) { @@ -95,10 +98,10 @@ export async function getVendors(input: GetVendorsSchema) { ilike(vendorsWithTypesView.vendorTypeName, s) ); } - + // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); - + // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName @@ -109,10 +112,10 @@ export async function getVendors(input: GetVendorsSchema) { ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); - + // 실제 사용될 where const where = finalWhere; - + // 정렬 const orderBy = input.sort.length > 0 @@ -120,7 +123,7 @@ export async function getVendors(input: GetVendorsSchema) { item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) : [asc(vendorsWithTypesView.createdAt)]; - + // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 (view 사용) @@ -130,7 +133,7 @@ export async function getVendors(input: GetVendorsSchema) { offset, limit: input.perPage, }); - + // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { @@ -142,7 +145,7 @@ export async function getVendors(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + return { ...vendor, hasAttachments: attachments.length > 0, @@ -150,17 +153,17 @@ export async function getVendors(input: GetVendorsSchema) { }; }) ); - + // 3) 전체 개수 const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); console.log(total) - + // 페이지 수 const pageCount = Math.ceil(total / input.perPage); - + return { data, pageCount }; } catch (err) { console.error("Error fetching vendors:", err); @@ -231,11 +234,11 @@ async function storeVendorFiles( files: File[], attachmentType: string ) { - + for (const file of files) { - const saveResult = await saveFile({file, directory:`vendors/${vendorId}` }) + const saveResult = await saveFile({ file, directory: `vendors/${vendorId}` }) // Insert attachment record await tx.insert(vendorAttachments).values({ @@ -250,7 +253,7 @@ async function storeVendorFiles( export async function getVendorTypes() { unstable_noStore(); // Next.js server action caching prevention - + try { const types = await db .select({ @@ -261,7 +264,7 @@ export async function getVendorTypes() { }) .from(vendorTypes) .orderBy(vendorTypes.nameKo); - + return { data: types, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; @@ -278,12 +281,12 @@ export type CreateVendorData = { address?: string email: string phone?: string - + representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string - + creditAgency?: string creditRating?: string cashFlowRating?: string @@ -299,7 +302,7 @@ export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -312,17 +315,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 { @@ -330,14 +333,14 @@ 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 { @@ -345,7 +348,7 @@ export async function createVendor(params: { error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` }; } - + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -360,36 +363,36 @@ export async function createVendor(params: { 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, @@ -401,7 +404,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -414,7 +417,7 @@ export async function createVendor(params: { /** 단건 업데이트 */ export async function modifyVendor( - input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가 + input: UpdateVendorSchema & { id: string; userId: number; comment: string; } // userId 추가 ) { unstable_noStore(); try { @@ -915,16 +918,16 @@ export async function deleteVendorItem( ) ) - revalidateTag(`vendor-items-${vendorId}`); - + revalidateTag(`vendor-items-${vendorId}`); + return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) - return { - success: false, - message: error instanceof z.ZodError - ? error.errors[0].message - : "Failed to delete item" + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" } } } @@ -935,7 +938,7 @@ export async function updateVendorItem( newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - + try { const validatedData = updateVendorItemSchema.parse({ oldItemCode, @@ -963,12 +966,12 @@ export async function updateVendorItem( // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -979,7 +982,7 @@ export async function removeVendorItems(input: { vendorId: number }) { unstable_noStore() - + try { const validatedData = removeVendorItemsSchema.parse(input) @@ -993,12 +996,12 @@ export async function removeVendorItems(input: { ) revalidateTag(`vendor-items-${validatedData.vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1111,16 +1114,16 @@ export async function deleteVendorMaterial( ) ) - revalidateTag(`vendor-materials-${vendorId}`); - + revalidateTag(`vendor-materials-${vendorId}`); + return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) - return { - success: false, - message: error instanceof z.ZodError - ? error.errors[0].message - : "Failed to delete item" + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" } } } @@ -1131,7 +1134,7 @@ export async function updateVendorMaterial( newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - + try { const validatedData = updateVendorMaterialSchema.parse({ oldItemCode, @@ -1159,12 +1162,12 @@ export async function updateVendorMaterial( // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1175,7 +1178,7 @@ export async function removeVendorMaterials(input: { vendorId: number }) { unstable_noStore() - + try { const validatedData = removeVendormaterialsSchema.parse(input) @@ -1189,12 +1192,12 @@ export async function removeVendorMaterials(input: { ) revalidateTag(`vendor-materials-${validatedData.vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1366,13 +1369,13 @@ interface CreateCompanyInput { * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ -export async function downloadVendorAttachments(vendorId:number, fileId?:number) { +export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // 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', @@ -1380,29 +1383,29 @@ export async function downloadVendorAttachments(vendorId:number, fileId?:number) 'Content-Type': 'application/json', }, }); - + if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } - + // 파일명 가져오기 (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, ''); } } - + // Blob으로 응답 변환 const blob = await response.blob(); - + // Blob URL 생성 const blobUrl = window.URL.createObjectURL(blob); - - return { + + return { url: blobUrl, fileName, blob @@ -1442,9 +1445,9 @@ interface ApproveVendorsInput { */ export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); - + try { - // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 활성화 및 이메일 발송 const result = await db.transaction(async (tx) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx @@ -1465,73 +1468,135 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 협력업체 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함) const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, + country: vendors.country, // 언어 설정용 국가 정보 }) .from(vendors) .where(inArray(vendors.id, input.ids)); - // 3. 각 벤더에 대한 유저 계정 생성 + // 3. 각 벤더에 대한 유저 계정 처리 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - // 이미 존재하는 유저인지 확인 - const existingUser = await tx.query.users.findFirst({ - where: eq(users.email, vendor.email), - columns: { - id: true + // 기존 유저 확인 (비활성 포함) + const existingUser = await tx + .select({ + id: users.id, + isActive: users.isActive, + language: users.language, + }) + .from(users) + .where(eq(users.email, vendor.email)) + .limit(1); + + let currentUser; + + if (existingUser.length > 0) { + // 🔄 기존 사용자 존재 시 - 활성화 + const user = existingUser[0]; + console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`); + + if (!user.isActive) { + // 비활성 사용자 활성화 + const [activatedUser] = await tx + .update(users) + .set({ + isActive: true, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)) + .returning({ id: users.id }); + + console.log(`✅ 사용자 활성화 완료: ${vendor.email} (ID: ${user.id})`); + currentUser = { id: user.id }; + } else { + console.log(`ℹ️ 사용자가 이미 활성 상태: ${vendor.email}`); + currentUser = { id: user.id }; } - }); + } else { + // 🆕 새 사용자 생성 (회원가입을 거치지 않은 경우 - 드문 케이스) + console.log(`🆕 새 사용자 생성: ${vendor.email}`); + + // 국가코드에 따른 언어 설정 + const language = vendor.country === 'KR' ? 'ko' : 'en'; - // 유저가 존재하지 않는 경우에만 생성 - if (!existingUser) { - // 유저 생성 const [newUser] = await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, companyId: vendor.id, - domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정 + domain: "partners", + language, // 국가별 언어 설정 + isActive: true, // 승인과 동시에 활성화 + // 기본 보안 설정 + mfaEnabled: false, + isLocked: false, + failedLoginAttempts: 0, + passwordChangeRequired: true, // 패스워드 설정 필요 + requiresConsentUpdate: false, }).returning({ id: users.id }); - // "Vendor Admin" 역할 찾기 또는 생성 - let vendorAdminRole = await tx.query.roles.findFirst({ - where: and( - eq(roles.name, "Vendor Admin"), - eq(roles.domain, "partners"), - eq(roles.companyId, vendor.id) - ), - columns: { - id: true - } - }); + console.log(`✅ 새 사용자 생성 완료: ${vendor.email} (ID: ${newUser.id}, 언어: ${language})`); + currentUser = newUser; + } - // "Vendor Admin" 역할이 없다면 생성 - if (!vendorAdminRole) { - const [newRole] = await tx.insert(roles).values({ - name: "Vendor Admin", - domain: "partners", - companyId: vendor.id, - description: "Vendor Administrator role", - }).returning({ id: roles.id }); - - vendorAdminRole = newRole; + // 4. 역할 할당 (기존 로직 유지) + // "Vendor Admin" 역할 찾기 또는 생성 + let vendorAdminRole = await tx.query.roles.findFirst({ + where: and( + eq(roles.name, "Vendor Admin"), + eq(roles.domain, "partners"), + eq(roles.companyId, vendor.id) + ), + columns: { + id: true } + }); - // userRoles 테이블에 관계 생성 + // "Vendor Admin" 역할이 없다면 생성 + if (!vendorAdminRole) { + const [newRole] = await tx.insert(roles).values({ + name: "Vendor Admin", + domain: "partners", + companyId: vendor.id, + description: "Vendor Administrator role", + }).returning({ id: roles.id }); + + vendorAdminRole = newRole; + console.log(`🎭 새 역할 생성: Vendor Admin (업체: ${vendor.vendorName})`); + } + + // 기존 사용자-역할 관계 확인 + const existingUserRole = await tx + .select({ id: userRoles.id }) + .from(userRoles) + .where( + and( + eq(userRoles.userId, currentUser.id), + eq(userRoles.roleId, vendorAdminRole.id) + ) + ) + .limit(1); + + // 역할이 할당되지 않은 경우에만 추가 + if (existingUserRole.length === 0) { await tx.insert(userRoles).values({ - userId: newUser.id, + userId: currentUser.id, roleId: vendorAdminRole.id, }); + console.log(`🔗 역할 할당: 사용자 ${currentUser.id} → Vendor Admin`); + } else { + console.log(`ℹ️ 역할이 이미 할당됨: 사용자 ${currentUser.id}`); } }) ); - // 4. 로그 기록 + // 5. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ @@ -1540,44 +1605,73 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_REVIEW", - comment: "Vendor approved for review", + comment: "Vendor approved and user account activated", }); }) ); - // 5. 각 벤더에게 이메일 발송 + // 6. 각 벤더에게 승인 완료 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 + // 사용자 언어 확인 + const userInfo = await tx + .select({ language: users.language }) + .from(users) + .where(eq(users.email, vendor.email)) + .limit(1); + + // 새 토큰 생성 (32바이트 랜덤) + const resetToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료 + + await db.insert(mfaTokens).values({ + userId: userInfo[0].id, + token: resetToken, + type: 'password_reset', + expiresAt, + isActive: true, + }); - const subject = - "[eVCP] Admin Account Created"; + const userLang = userInfo.length > 0 ? userInfo[0].language : + (vendor.country === 'KR' ? 'ko' : 'en'); + + const subject = userLang === 'ko' + ? "[eVCP] 업체 승인 완료 - 계정 활성화" + : "[eVCP] Vendor Approved - Account Activated"; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; - const baseUrl = `http://${host}` - const loginUrl = `${baseUrl}/en/login`; + const protocol = headersList.get('x-forwarded-proto') || 'http'; + const baseUrl = `${protocol}://${host}`; + const loginUrl = `${baseUrl}/${userLang}/login`; + const passwordSetupUrl = `${baseUrl}/${userLang}/auth/reset-password?token=${resetToken}`; // 패스워드 설정 URL await sendEmail({ to: vendor.email, subject, - template: "admin-created", // 이메일 템플릿 이름 + template: "vendor-approved", // 승인 완료 템플릿 context: { vendorName: vendor.vendorName, loginUrl, + passwordSetupUrl, language: userLang, + isNewAccount: false, // 기존 계정 활성화임을 표시 }, }); + + console.log(`📧 승인 완료 이메일 발송: ${vendor.email}`); } catch (emailError) { - console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); + console.log(`🎉 협력업체 승인 완료: ${updatedVendors.length}개 업체`); return updated; }); @@ -1590,7 +1684,7 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb return { data: result, error: null }; } catch (err) { - console.error("Error approving vendors:", err); + console.error("협력업체 승인 처리 오류:", err); return { data: null, error: getErrorMessage(err) }; } } @@ -1612,11 +1706,11 @@ export async function generatePQNumber(isProject: boolean = false) { const month = (now.getMonth() + 1).toString().padStart(2, '0'); // 월 (01-12) const day = now.getDate().toString().padStart(2, '0'); // 일 (01-31) const dateStr = `${year}${month}${day}`; - + // 접두사 설정 (일반 PQ vs 프로젝트 PQ) const prefix = isProject ? "PPQ" : "PQ"; const datePrefix = `${prefix}-${dateStr}`; - + // 오늘 생성된 가장 큰 시퀀스 번호 조회 const latestPQ = await db .select({ pqNumber: vendorPQSubmissions.pqNumber }) @@ -1626,9 +1720,9 @@ export async function generatePQNumber(isProject: boolean = false) { ) .orderBy(desc(vendorPQSubmissions.pqNumber)) .limit(1); - + let sequenceNumber = 1; // 기본값은 1 - + // 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산 if (latestPQ.length > 0 && latestPQ[0].pqNumber) { const lastPQ = latestPQ[0].pqNumber; @@ -1637,13 +1731,13 @@ export async function generatePQNumber(isProject: boolean = false) { sequenceNumber = parseInt(lastSequence) + 1; } } - + // 5자리 시퀀스 번호로 포맷팅 (00001, 00002, ...) const formattedSequence = sequenceNumber.toString().padStart(5, '0'); - + // 최종 PQ 번호 생성 const pqNumber = `${datePrefix}-${formattedSequence}`; - + return pqNumber; } catch (error) { console.error('Error generating PQ number:', error); @@ -1654,206 +1748,7 @@ export async function generatePQNumber(isProject: boolean = false) { } } -export async function requestPQVendors(input: ApproveVendorsInput & { - userId: number, - agreements?: Record<string, boolean>, - dueDate?: string | null, - type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", - extraNote?: string, - pqItems?: string -}) { - unstable_noStore(); - const session = await getServerSession(authOptions); - const requesterId = session?.user?.id ? Number(session.user.id) : null; - - try { - let projectInfo = null; - if (input.projectId) { - const project = await db - .select({ - id: projects.id, - projectCode: projects.code, - projectName: projects.name, - }) - .from(projects) - .where(eq(projects.id, input.projectId)) - .limit(1); - - if (project.length > 0) { - projectInfo = project[0]; - } - } - - const result = await db.transaction(async (tx) => { - const vendorsBeforeUpdate = await tx - .select({ id: vendors.id, status: vendors.status }) - .from(vendors) - .where(inArray(vendors.id, input.ids)); - - const [updated] = await tx - .update(vendors) - .set({ status: "IN_PQ", updatedAt: new Date() }) - .where(inArray(vendors.id, input.ids)) - .returning(); - - const updatedVendors = await tx - .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) - .from(vendors) - .where(inArray(vendors.id, input.ids)); - - const pqType = input.type; - const currentDate = new Date(); - - const existingSubmissions = await tx - .select({ vendorId: vendorPQSubmissions.vendorId }) - .from(vendorPQSubmissions) - .where( - and( - inArray(vendorPQSubmissions.vendorId, input.ids), - pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, - input.projectId - ? eq(vendorPQSubmissions.projectId, input.projectId) - : isNull(vendorPQSubmissions.projectId) - ) - ); - - const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); - const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); - - if (newVendorIds.length > 0) { - const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { - const pqNumber = await generatePQNumber(pqType === "PROJECT"); - - return { - vendorId, - pqNumber, - projectId: input.projectId || null, - type: pqType, - status: "REQUESTED", - requesterId: input.userId || requesterId, - dueDate: input.dueDate ? new Date(input.dueDate) : null, - agreements: input.agreements ?? {}, - pqItems: input.pqItems || null, - createdAt: currentDate, - updatedAt: currentDate, - }; - }); - - const vendorPQData = await Promise.all(vendorPQDataPromises); - - await tx.insert(vendorPQSubmissions).values(vendorPQData); - } - - 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})` - : "General PQ requested", - }); - }) - ); - - const headersList = await headers(); - const host = headersList.get("host") || "localhost:3000"; - - await Promise.all( - updatedVendors.map(async (vendor) => { - if (!vendor.email) return; - - try { - const userLang = "en"; - - const vendorPQ = await tx - .select({ pqNumber: vendorPQSubmissions.pqNumber }) - .from(vendorPQSubmissions) - .where( - and( - eq(vendorPQSubmissions.vendorId, vendor.id), - eq(vendorPQSubmissions.type, pqType), - input.projectId - ? eq(vendorPQSubmissions.projectId, input.projectId) - : isNull(vendorPQSubmissions.projectId) - ) - ) - .limit(1) - .then((rows) => rows[0]); - - const subject = input.projectId - ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` - : input.type === "NON_INSPECTION" - ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` - : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; - - const baseLoginUrl = `${host}/partners/pq`; - const loginUrl = input.projectId - ? `${baseLoginUrl}?projectId=${input.projectId}` - : baseLoginUrl; - - // 체크된 계약 항목 배열 생성 - const contracts = input.agreements - ? Object.entries(input.agreements) - .filter(([_, checked]) => checked) - .map(([name, _]) => name) - : []; - - // PQ 대상 품목 - const pqItems = input.pqItems || " - "; - - await sendEmail({ - to: vendor.email, - subject, - template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", - context: { - vendorName: vendor.vendorName, - vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 - pqNumber: vendorPQ?.pqNumber || "", - senderName: session?.user?.name || "eVCP", - senderEmail: session?.user?.email || "noreply@evcp.com", - dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", - pqItems, - contracts, - extraNote: input.extraNote || "", - currentYear: new Date().getFullYear().toString(), - loginUrl, - language: userLang, - projectCode: projectInfo?.projectCode || "", - projectName: projectInfo?.projectName || "", - hasProject: !!input.projectId, - pqType: input.type || "GENERAL", - }, - }); - } catch (emailError) { - console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); - } - }) - ); - - return updated; - }); - - revalidateTag("vendors"); - revalidateTag("vendor-status-counts"); - revalidateTag("vendor-pq-submissions"); - revalidateTag("pq-submissions"); - - if (input.projectId) { - revalidateTag(`project-${input.projectId}`); - revalidateTag(`project-pq-submissions-${input.projectId}`); - } - - return { data: result, error: null }; - } catch (err) { - console.error("Error requesting PQ from vendors:", err); - return { data: null, error: getErrorMessage(err) }; - } -} interface SendVendorsInput { @@ -2054,7 +1949,7 @@ export async function requestInfo({ ids, userId }: RequestInfoProps) { const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; - + // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 for (const vendor of vendorList) { // 로그 기록 @@ -2290,7 +2185,7 @@ export async function updateVendorInfo(params: { // 3-2. 파일 시스템에서 파일 삭제 for (const attachment of attachmentsToDelete) { try { - + await deleteFile(attachment.filePath) } catch (error) { @@ -2400,7 +2295,7 @@ export async function exportVendorContacts(vendorId: number) { .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorId)) .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); - + return contacts; } catch (error) { console.error("Failed to export vendor contacts:", error); @@ -2427,7 +2322,7 @@ export async function exportVendorItems(vendorId: number) { .from(vendorItemsView) .where(eq(vendorItemsView.vendorId, vendorId)) .orderBy(vendorItemsView.itemName); - + return vendorItems; } catch (error) { console.error("Failed to export vendor items:", error); @@ -2446,7 +2341,7 @@ export async function exportVendorRFQs(vendorId: number) { .from(vendorRfqView) .where(eq(vendorRfqView.vendorId, vendorId)) .orderBy(vendorRfqView.rfqVendorUpdated); - + return rfqs; } catch (error) { console.error("Failed to export vendor RFQs:", error); @@ -2465,7 +2360,7 @@ export async function exportVendorContracts(vendorId: number) { .from(contractsDetailView) .where(eq(contractsDetailView.vendorId, vendorId)) .orderBy(contractsDetailView.createdAt); - + return contracts; } catch (error) { console.error("Failed to export vendor contracts:", error); @@ -2480,7 +2375,7 @@ export async function exportVendorContracts(vendorId: number) { export async function exportVendorDetails(vendorIds: number[]) { try { if (!vendorIds.length) return []; - + // 벤더 기본 정보 조회 const vendorsData = await db .select({ @@ -2507,8 +2402,8 @@ export async function exportVendorDetails(vendorIds: number[]) { }) .from(vendors) .where( - vendorIds.length === 1 - ? eq(vendors.id, vendorIds[0]) + vendorIds.length === 1 + ? eq(vendors.id, vendorIds[0]) : inArray(vendors.id, vendorIds) ); @@ -2517,16 +2412,16 @@ export async function exportVendorDetails(vendorIds: number[]) { 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, @@ -2536,7 +2431,7 @@ export async function exportVendorDetails(vendorIds: number[]) { }; }) ); - + return vendorsWithDetails; } catch (error) { console.error("Failed to export vendor details:", error); @@ -2551,7 +2446,7 @@ export async function exportVendorDetails(vendorIds: number[]) { export async function searchVendors(searchTerm: string = "", limit: number = 100) { try { let whereCondition; - + if (searchTerm.trim()) { const s = `%${searchTerm.trim()}%`; whereCondition = or( @@ -2559,7 +2454,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 ilike(vendorsWithTypesView.vendorCode, s) ); } - + const vendors = await db .select({ id: vendorsWithTypesView.id, @@ -2578,7 +2473,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 ) .orderBy(asc(vendorsWithTypesView.vendorName)) .limit(limit); - + return vendors; } catch (error) { console.error("벤더 검색 오류:", error); @@ -2592,7 +2487,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 */ export async function getVendorBasicInfo(vendorId: number) { unstable_noStore(); - + try { return await db.transaction(async (tx) => { // 1. 기본 벤더 정보 조회 (vendorsWithTypesView 사용) @@ -2647,7 +2542,7 @@ export async function getVendorBasicInfo(vendorId: number) { cashFlowRating: vendor.cashFlowRating, createdAt: vendor.createdAt, updatedAt: vendor.updatedAt, - + // 연락처 정보 contacts: contacts.map(contact => ({ id: contact.id, @@ -2657,7 +2552,7 @@ export async function getVendorBasicInfo(vendorId: number) { contactPhone: contact.contactPhone, isPrimary: contact.isPrimary, })), - + // 첨부파일 정보 attachments: attachments.map(attachment => ({ id: attachment.id, @@ -2666,26 +2561,26 @@ export async function getVendorBasicInfo(vendorId: number) { attachmentType: attachment.attachmentType, createdAt: attachment.createdAt, })), - + // 추가 정보는 임시로 null (나중에 실제 데이터로 교체) additionalInfo: { businessType: vendor.vendorTypeId ? `Type ${vendor.vendorTypeId}` : null, employeeCount: 0, // 실제 데이터가 있을 수 있으므로 유지 mainBusiness: null, }, - + // 매출 정보 (구현 예정 - 나중에 실제 테이블 연결) salesInfo: null, // 구현 시 { "2023": { totalSales: "1000", totalDebt: "500", ... }, "2022": { ... } } 형태로 연도별 키 사용 - + // 추가 정보들 (구현 예정 - 나중에 실제 테이블 연결) organization: null, - + factoryInfo: null, - + inspectionInfo: null, - + evaluationInfo: null, - + classificationInfo: { vendorClassification: null, groupCompany: null, @@ -2693,11 +2588,11 @@ export async function getVendorBasicInfo(vendorId: number) { industryType: "제조업", // 기본값으로 유지 isoCertification: null, }, - + contractDetails: null, - + capacityInfo: null, - + calculatedMetrics: null, // 구현 시 { "20231231": { debtRatio: 0, ... }, "20221231": { ... } } 형태로 YYYYMMDD 키 사용 }; }); @@ -2705,4 +2600,373 @@ export async function getVendorBasicInfo(vendorId: number) { console.error("Error fetching vendor basic info:", error); return null; } -}
\ No newline at end of file +} +interface RequestBasicContractInfoProps { + vendorIds: number[]; + requestedBy: number; + templateId: number; + pdfBuffer?: Buffer | Uint8Array | ArrayBuffer; // 생성된 PDF 버퍼 (선택적, 다양한 타입 지원) +} + + +export async function requestPQVendors(input: ApproveVendorsInput & { + userId: number, + agreements?: Record<string, boolean>, + dueDate?: string | null, + type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", + extraNote?: string, + pqItems?: string, + templateId?: number | null +}) { + unstable_noStore(); + + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + + try { + let projectInfo = null; + if (input.projectId) { + const project = await db + .select({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1); + + if (project.length > 0) { + projectInfo = project[0]; + } + } + + const result = await db.transaction(async (tx) => { + const vendorsBeforeUpdate = await tx + .select({ id: vendors.id, status: vendors.status }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + const [updated] = await tx + .update(vendors) + .set({ status: "IN_PQ", updatedAt: new Date() }) + .where(inArray(vendors.id, input.ids)) + .returning(); + + const updatedVendors = await tx + .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + const pqType = input.type; + const currentDate = new Date(); + + const existingSubmissions = await tx + .select({ vendorId: vendorPQSubmissions.vendorId }) + .from(vendorPQSubmissions) + .where( + and( + inArray(vendorPQSubmissions.vendorId, input.ids), + pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ); + + const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); + const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); + + if (newVendorIds.length > 0) { + const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { + const pqNumber = await generatePQNumber(pqType === "PROJECT"); + + return { + vendorId, + pqNumber, + projectId: input.projectId || null, + type: pqType, + status: "REQUESTED", + requesterId: input.userId || requesterId, + dueDate: input.dueDate ? new Date(input.dueDate) : null, + agreements: input.agreements ?? {}, + pqItems: input.pqItems || null, + createdAt: currentDate, + updatedAt: currentDate, + }; + }); + + const vendorPQData = await Promise.all(vendorPQDataPromises); + + await tx.insert(vendorPQSubmissions).values(vendorPQData); + } + + 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})` + : "General PQ requested", + }); + }) + ); + + const headersList = await headers(); + const host = headersList.get("host") || "localhost:3000"; + + await Promise.all( + updatedVendors.map(async (vendor) => { + if (!vendor.email) return; + + try { + const userLang = "en"; + + const vendorPQ = await tx + .select({ pqNumber: vendorPQSubmissions.pqNumber }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.vendorId, vendor.id), + eq(vendorPQSubmissions.type, pqType), + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ) + .limit(1) + .then((rows) => rows[0]); + + const subject = input.projectId + ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` + : input.type === "NON_INSPECTION" + ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` + : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; + + const baseLoginUrl = `${host}/partners/pq`; + const loginUrl = input.projectId + ? `${baseLoginUrl}?projectId=${input.projectId}` + : baseLoginUrl; + + // 체크된 계약 항목 배열 생성 + const contracts = input.agreements + ? Object.entries(input.agreements) + .filter(([_, checked]) => checked) + .map(([name, _]) => name) + : []; + + // PQ 대상 품목 + const pqItems = input.pqItems || " - "; + + await sendEmail({ + to: vendor.email, + subject, + template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", + context: { + vendorName: vendor.vendorName, + vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 + pqNumber: vendorPQ?.pqNumber || "", + senderName: session?.user?.name || "eVCP", + senderEmail: session?.user?.email || "noreply@evcp.com", + dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", + pqItems, + contracts, + extraNote: input.extraNote || "", + currentYear: new Date().getFullYear().toString(), + loginUrl, + language: userLang, + projectCode: projectInfo?.projectCode || "", + projectName: projectInfo?.projectName || "", + hasProject: !!input.projectId, + pqType: input.type || "GENERAL", + }, + }); + } catch (emailError) { + console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + } + }) + ); + + return updated; + }); + + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("vendor-pq-submissions"); + revalidateTag("pq-submissions"); + + if (input.projectId) { + revalidateTag(`project-${input.projectId}`); + revalidateTag(`project-pq-submissions-${input.projectId}`); + } + + return { data: result, error: null }; + } catch (err) { + console.error("Error requesting PQ from vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function requestBasicContractInfo({ + vendorIds, + requestedBy, + templateId, + pdfBuffer +}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + if (!vendorIds || vendorIds.length === 0) { + return { error: "요청할 협력업체가 선택되지 않았습니다." }; + } + + if (!templateId) { + return { error: "계약서 템플릿이 선택되지 않았습니다." }; + } + + try { + // 1. 선택된 템플릿 정보 가져오기 + const template = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, templateId) + }); + + if (!template) { + return { error: "선택한 템플릿을 찾을 수 없습니다." }; + } + + // 2. PDF 버퍼가 제공된 경우 파일로 저장, 아니면 원본 템플릿 파일 사용 + let finalFileName = template.fileName || `${template.templateName}.docx`; + let finalFilePath = template.filePath || `/basicContract/${finalFileName}`; + + if (pdfBuffer) { + try { + const fileId = uuidv4(); + const fileName = `${fileId}.pdf`; + const relativePath = `/basicContract/${fileName}`; + const publicDir = path.join(process.cwd(), "public", "basicContract"); + const absolutePath = path.join(publicDir, fileName); + + // 디렉토리 생성 + await fs.mkdir(publicDir, { recursive: true }); + + // PDF 파일 저장 (다양한 타입을 Buffer로 변환) + let bufferData: Buffer; + if (Buffer.isBuffer(pdfBuffer)) { + bufferData = pdfBuffer; + } else if (pdfBuffer instanceof ArrayBuffer) { + bufferData = Buffer.from(pdfBuffer); + } else if (pdfBuffer instanceof Uint8Array) { + bufferData = Buffer.from(pdfBuffer); + } else { + bufferData = Buffer.from(pdfBuffer as any); + } + await fs.writeFile(absolutePath, bufferData); + + finalFileName = fileName; + finalFilePath = relativePath; + + console.log(`✅ PDF 파일 저장 완료: ${absolutePath}`); + } catch (pdfSaveError) { + console.error('PDF 파일 저장 오류:', pdfSaveError); + return { error: `PDF 파일 저장 실패: ${pdfSaveError instanceof Error ? pdfSaveError.message : '알 수 없는 오류'}` }; + } + } else if (!template.fileName || !template.filePath) { + return { error: "템플릿 파일 정보가 없고 PDF 버퍼도 제공되지 않았습니다." }; + } + + // 3. 협력업체 정보 가져오기 + const vendorList = await db + .select() + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + if (!vendorList || vendorList.length === 0) { + return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; + } + + // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송 + const results = await Promise.all( + vendorList.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + try { + // 3-1. basic_contract 테이블에 레코드 추가 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendor.id, + requestedBy: requestedBy, + status: "PENDING", + fileName: finalFileName, // PDF 변환된 파일 이름 사용 + filePath: finalFilePath, // PDF 변환된 파일 경로 사용 + }) + .returning(); + + // 3-2. 협력업체에 이메일 발송 + const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`; + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // 로그인 또는 서명 페이지 URL 생성 + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/partners/basic-contract`; + + // 사용자 언어 설정 (기본값은 한국어) + const userLang = "ko"; + + // 이메일 발송 + await sendEmail({ + to: vendor.email, + subject, + template: "contract-sign-request", // 이메일 템플릿 이름 + context: { + vendorName: vendor.vendorName, + contractId: newContract.id, + templateName: template.templateName, + loginUrl, + language: userLang, + }, + }); + + return { vendorId: vendor.id, success: true }; + } catch (err) { + console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err); + return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; + } + }) + ); + + // 4. 실패한 케이스가 있는지 확인 + const failedVendors = results.filter(r => r && !r.success); + + if (failedVendors.length > 0) { + console.error("일부 협력업체 처리 실패:", failedVendors); + if (failedVendors.length === vendorIds.length) { + // 모든 협력업체 처리 실패 + return { error: "모든 협력업체에 대한 처리가 실패했습니다." }; + } else { + // 일부 협력업체만 처리 실패 + return { + success: true, + error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패` + }; + } + } + + // 5. 캐시 무효화 + revalidateTag("basic-contract-requests"); + + return { success: true }; + } catch (error) { + console.error("기본계약서 요청 중 오류 발생:", error); + return { + error: error instanceof Error + ? error.message + : "기본계약서 요청 처리 중 오류가 발생했습니다." + }; + } +} |
