"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, 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 { selectVendors, countVendors, insertVendor, updateVendor, updateVendors, groupByStatus, getVendorById, getVendorContactsById, selectVendorContacts, countVendorContacts, insertVendorContact, selectVendorItems, countVendorItems, insertVendorItem, countRfqHistory, selectRfqHistory } from "./repository"; import type { CreateVendorSchema, UpdateVendorSchema, GetVendorsSchema, GetVendorContactsSchema, CreateVendorContactSchema, GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, } from "./validations"; import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import path from "path"; import fs from "fs/promises"; import { randomUUID } from "crypto"; import JSZip from 'jszip'; import { promises as fsPromises } from 'fs'; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items } from "@/db/schema/items"; import { id_ID } from "@faker-js/faker"; import { users } from "@/db/schema/users"; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getVendors(input: GetVendorsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 1) 고급 필터 const advancedWhere = filterColumns({ table: vendors, 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) ); } // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName ? ilike(vendors.vendorName, `%${input.vendorName}%`) : undefined, input.status ? ilike(vendors.status, input.status) : undefined, input.country ? ilike(vendors.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]) ) : [asc(vendors.createdAt)]; // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 const vendorsData = await selectVendors(tx, { where, orderBy, offset, limit: input.perPage, }); // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { const attachments = await tx .select({ id: vendorAttachments.id, fileName: vendorAttachments.fileName, filePath: vendorAttachments.filePath, }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); return { ...vendor, hasAttachments: attachments.length > 0, attachmentsList: attachments, }; }) ); // 3) 전체 개수 const total = await countVendors(tx, where); return { data: vendorsWithAttachments, total }; }); // 페이지 수 const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화 } )(); } export async function getVendorStatusCounts() { return unstable_cache( async () => { try { const initial: Record = { ACTIVE: 0, INACTIVE: 0, BLACKLISTED: 0, "PENDING_REVIEW": 0, "IN_REVIEW": 0, "REJECTED": 0, "IN_PQ": 0, "PQ_FAILED": 0, "APPROVED": 0, "PQ_SUBMITTED": 0 }; const result = await db.transaction(async (tx) => { const rows = await groupByStatus(tx); return rows.reduce>((acc, { status, count }) => { acc[status] = count; return acc; }, initial); }); return result; } catch (err) { return {} as Record; } }, ["task-status-counts"], // 캐싱 키 { revalidate: 3600, } )(); } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ /** * 신규 Vendor 생성 */ async function storeVendorFiles( tx: PgTransaction, vendorId: number, files: File[], attachmentType: string ) { const vendorDir = path.join( process.cwd(), "public", "vendors", String(vendorId) ) await fs.mkdir(vendorDir, { recursive: true }) for (const file of files) { // Convert file to buffer const ab = await file.arrayBuffer() const buffer = Buffer.from(ab) // Generate a unique filename const uniqueName = `${randomUUID()}-${file.name}` const relativePath = path.join("vendors", String(vendorId), uniqueName) const absolutePath = path.join(process.cwd(), "public", relativePath) // Write to disk await fs.writeFile(absolutePath, buffer) // Insert attachment record await tx.insert(vendorAttachments).values({ vendorId, fileName: file.name, filePath: "/" + relativePath.replace(/\\/g, "/"), attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ... }) } } export type CreateVendorData = { vendorName: string vendorCode?: 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" } export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] contacts: { contactName: string contactPosition?: string contactEmail: string contactPhone?: string isPrimary?: boolean }[] }) { 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 { data: null, error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { vendorName: vendorData.vendorName, vendorCode: vendorData.vendorCode || null, address: vendorData.address || null, country: vendorData.country || null, phone: vendorData.phone || null, email: vendorData.email, website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, // 대표자 정보 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, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary ?? false, }) } }) revalidateTag("vendors") return { data: null, error: null } } catch (error) { return { data: null, error: getErrorMessage(error) } } } /* ----------------------------------------------------- 3) 업데이트 (단건/복수) ----------------------------------------------------- */ /** 단건 업데이트 */ export async function modifyVendor( input: UpdateVendorSchema & { id: string } ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { // 특정 ID 벤더를 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, address: input.address, country: input.country, phone: input.phone, email: input.email, website: input.website, status: input.status, }); return res; }); // 필요 시, status 변경 등에 따른 다른 캐시도 무효화 revalidateTag("vendors"); revalidateTag("rfq-vendors"); return { data: updated, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 업데이트 */ export async function modifyVendors(input: { ids: string[]; status?: Vendor["status"]; }) { unstable_noStore(); try { const data = await db.transaction(async (tx) => { // 여러 벤더 일괄 업데이트 const [updated] = await updateVendors(tx, input.ids, { // 예: 상태만 일괄 변경 status: input.status, }); return updated; }); revalidateTag("vendors"); if (data.status === input.status) { revalidateTag("vendor-status-counts"); } return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export const findVendorById = async (id: number): Promise => { try { logger.info({ id }, 'Fetching user by ID'); const vendor = await getVendorById(id); if (!vendor) { logger.warn({ id }, 'User not found'); } else { logger.debug({ vendor }, 'User fetched successfully'); } return vendor; } catch (error) { logger.error({ error }, 'Error fetching user by ID'); throw new Error('Failed to fetch user'); } }; export const findVendorContactsById = async (id: number): Promise => { try { logger.info({ id }, 'Fetching user by ID'); const vendor = await getVendorContactsById(id); if (!vendor) { logger.warn({ id }, 'User not found'); } else { logger.debug({ vendor }, 'User fetched successfully'); } return vendor; } catch (error) { logger.error({ error }, 'Error fetching user by ID'); throw new Error('Failed to fetch user'); } }; export async function getVendorContacts(input: GetVendorContactsSchema, id: number) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // const advancedTable = input.flags.includes("advancedTable"); const advancedTable = true; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: vendorContacts, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s) , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorContacts.vendorId, id) const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere, vendorWhere ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id]) ) : [asc(vendorContacts.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorContacts(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorContacts(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화 } )(); } export async function createVendorContact(input: CreateVendorContactSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorContact(tx, { vendorId: input.vendorId, contactName: input.contactName, contactPosition: input.contactPosition || "", contactEmail: input.contactEmail, contactPhone: input.contactPhone || "", isPrimary: input.isPrimary || false, }); return newContact; }); // 캐시 무효화 (벤더 연락처 목록 등) revalidateTag(`vendor-contacts-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } ///item export async function getVendorItems(input: GetVendorItemsSchema, id: number) { const cachedFunction = unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // const advancedTable = input.flags.includes("advancedTable"); const advancedTable = true; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: vendorItemsView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorItemsView.itemCode, s) , ilike(vendorItemsView.description, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorItemsView.vendorId, id) const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere, vendorWhere ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id]) ) : [asc(vendorItemsView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorItems(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorItems(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); console.log(data) return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화 } ); return cachedFunction(); } export interface ItemDropdownOption { itemCode: string; itemName: string; description: string | null; } /** * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) * 아이템 코드, 이름, 설명만 간소화해서 반환 */ export async function getItemsForVendor(vendorId: number) { return unstable_cache( async () => { try { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db .select({ itemCode: items.itemCode, itemName: items.itemName, description: items.description, }) .from(items) .leftJoin( vendorPossibleItems, eq(items.itemCode, vendorPossibleItems.itemCode) ) // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 .where( isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) ) .orderBy(asc(items.itemName)) return { data: itemsData.map((item) => ({ itemCode: item.itemCode ?? "", // null이라면 ""로 치환 itemName: item.itemName, description: item.description ?? "" // null이라면 ""로 치환 })), error: null } } catch (err) { console.error("Failed to fetch items for vendor dropdown:", err) return { data: [], error: "아이템 목록을 불러오는데 실패했습니다.", } } }, // 캐시 키를 vendorId 별로 달리 해야 한다. ["items-for-vendor", String(vendorId)], { revalidate: 3600, // 1시간 캐싱 tags: ["items"], // revalidateTag("items") 호출 시 무효화 } )() } export async function createVendorItem(input: CreateVendorItemSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorItem(tx, { vendorId: input.vendorId, itemCode: input.itemCode, }); return newContact; }); // 캐시 무효화 (벤더 연락처 목록 등) revalidateTag(`vendor-items-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { return unstable_cache( async () => { try { logger.info({ vendorId, input }, "Starting getRfqHistory"); const offset = (input.page - 1) * input.perPage; // 기본 where 조건 (vendorId) const vendorWhere = eq(vendorRfqView.vendorId, vendorId); logger.debug({ vendorWhere }, "Vendor where condition"); // 고급 필터링 const advancedWhere = filterColumns({ table: vendorRfqView, filters: input.filters, joinOperator: input.joinOperator, }); logger.debug({ advancedWhere }, "Advanced where condition"); // 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(vendorRfqView.rfqCode, s), ilike(vendorRfqView.projectCode, s), ilike(vendorRfqView.projectName, s) ); logger.debug({ globalWhere, search: input.search }, "Global search condition"); } const finalWhere = and( advancedWhere, globalWhere, vendorWhere ); logger.debug({ finalWhere }, "Final where condition"); // 정렬 조건 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id]) ) : [desc(rfqs.createdAt)]; logger.debug({ orderBy }, "Order by condition"); // 트랜잭션으로 데이터 조회 const { data, total } = await db.transaction(async (tx) => { logger.debug("Starting transaction for RFQ history query"); const data = await selectRfqHistory(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); logger.debug({ dataLength: data.length }, "RFQ history data fetched"); // RFQ 아이템 정보 조회 const rfqIds = data.map(rfq => rfq.id); const items = await tx .select({ rfqId: rfqItems.rfqId, id: rfqItems.id, itemCode: rfqItems.itemCode, description: rfqItems.description, quantity: rfqItems.quantity, uom: rfqItems.uom, }) .from(rfqItems) .where(inArray(rfqItems.rfqId, rfqIds)); // RFQ 데이터에 아이템 정보 추가 const dataWithItems = data.map(rfq => ({ ...rfq, items: items.filter(item => item.rfqId === rfq.id), })); const total = await countRfqHistory(tx, finalWhere); logger.debug({ total }, "RFQ history total count"); return { data: dataWithItems, total }; }); const pageCount = Math.ceil(total / input.perPage); logger.info({ vendorId, dataLength: data.length, total, pageCount }, "RFQ history query completed"); return { data, pageCount }; } catch (err) { logger.error({ err, vendorId, stack: err instanceof Error ? err.stack : undefined }, 'Error fetching RFQ history'); return { data: [], pageCount: 0 }; } }, [JSON.stringify({ input, vendorId })], { revalidate: 3600, tags: ["rfq-history"], } )(); } export async function checkJoinPortal(taxID: string) { try { // 이미 등록된 회사가 있는지 검색 const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1) if (result.length > 0) { // 이미 가입되어 있음 // data에 예시로 vendorName이나 다른 정보를 담아 반환 return { success: false, data: result[0].vendorName ?? "Already joined", } } // 미가입 → 가입 가능 return { success: true, } } catch (err) { console.error("checkJoinPortal error:", err) // 서버 에러 시 return { success: false, data: "서버 에러가 발생했습니다.", } } } interface CreateCompanyInput { vendorName: string taxId: string email: string address: string phone?: string country?: string // 필요한 필드 추가 가능 (vendorCode, website 등) } /** * 벤더 첨부파일 다운로드를 위한 서버 액션 * @param vendorId 벤더 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // 벤더 정보 조회 const vendor = await db.select() .from(vendors) .where(eq(vendors.id, vendorId)) .limit(1) .then(rows => rows[0]); 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('다운로드할 첨부파일이 없습니다.'); } // 업로드 기본 경로 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 }; } // 다중 파일: 임시 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` }; } catch (error) { console.error('첨부파일 다운로드 서버 액션 오류:', error); throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.'); } } /** * 임시 ZIP 파일 정리를 위한 서버 액션 * @param fileName 정리할 파일명 */ export async function cleanupTempFiles(fileName: string) { 'use server'; try { const tempDir = path.join(process.cwd(), 'tmp'); const filePath = path.join(tempDir, fileName); try { // 파일 존재 확인 await fsPromises.access(filePath, fs.constants.F_OK); // 파일 삭제 await fsPromises.unlink(filePath); } catch { // 파일이 없으면 무시 } return { success: true }; } catch (error) { console.error('임시 파일 정리 오류:', error); return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; } } interface ApproveVendorsInput { ids: number[]; } /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ export async function approveVendors(input: ApproveVendorsInput) { unstable_noStore(); try { // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { // 1. 벤더 상태 업데이트 const [updated] = await tx .update(vendors) .set({ status: "IN_REVIEW", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 3. 각 벤더에 대한 유저 계정 생성 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 // 이미 존재하는 유저인지 확인 const existingUser = await tx .select({ id: users.id }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); // 유저가 존재하지 않는 경우에만 생성 if (existingUser.length === 0) { await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, companyId: vendor.id, domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정 }); } }) ); // 4. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 const subject = "[eVCP] Admin Account Created"; const loginUrl = "http://3.36.56.124:3000/en/login"; await sendEmail({ to: vendor.email, subject, template: "admin-created", // 이메일 템플릿 이름 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, }, }); } catch (emailError) { console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); return updated; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("users"); // 유저 캐시도 무효화 return { data: result, error: null }; } catch (err) { console.error("Error approving vendors:", err); return { data: null, error: getErrorMessage(err) }; } } export async function requestPQVendors(input: ApproveVendorsInput) { unstable_noStore(); try { // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { // 1. 벤더 상태 업데이트 const [updated] = await tx .update(vendors) .set({ status: "IN_PQ", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 3. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 const subject = "[eVCP] You are invited to submit PQ"; const loginUrl = "http://3.36.56.124:3000/en/login"; await sendEmail({ to: vendor.email, subject, template: "pq", // 이메일 템플릿 이름 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, }, }); } catch (emailError) { console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); return updated; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); return { data: result, error: null }; } catch (err) { console.error("Error approving vendors:", err); return { data: null, error: getErrorMessage(err) }; } } interface SendVendorsInput { ids: number[]; } /** * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션 */ export async function sendVendors(input: SendVendorsInput) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await tx .select() .from(vendors) .where( and( inArray(vendors.id, input.ids), eq(vendors.status, "APPROVED") ) ); if (!approvedVendors.length) { throw new Error("No approved vendors found in the selection"); } // 벤더별 처리 결과를 저장할 배열 const results = []; // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { // 2-1. 벤더 연락처 정보 조회 const contacts = await tx .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendor.id)); // 2-2. 벤더 가능 아이템 조회 const possibleItems = await tx .select() .from(vendorPossibleItems) .where(eq(vendorPossibleItems.vendorId, vendor.id)); // 2-3. 벤더 첨부파일 조회 const attachments = await tx .select({ id: vendorAttachments.id, fileName: vendorAttachments.fileName, filePath: vendorAttachments.filePath, }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { id: vendor.id, vendorName: vendor.vendorName, taxId: vendor.taxId, address: vendor.address || "", country: vendor.country || "", phone: vendor.phone || "", email: vendor.email || "", website: vendor.website || "", contacts, possibleItems, attachments, }; try { // 내부 API 호출 (기간계 시스템 연동 API) const erpResponse = await fetch(`/api/erp/vendors`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(vendorData), }); if (!erpResponse.ok) { const errorData = await erpResponse.json(); throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`); } const responseData = await erpResponse.json(); if (!responseData.success || !responseData.vendorCode) { throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); } // 2-5. 벤더 코드 및 상태 업데이트 const vendorCode = responseData.vendorCode; const [updated] = await tx .update(vendors) .set({ vendorCode, status: "ACTIVE", // 상태를 ACTIVE로 변경 updatedAt: new Date(), }) .where(eq(vendors.id, vendor.id)) .returning(); // 2-6. 벤더에게 알림 이메일 발송 if (vendor.email) { 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`; await sendEmail({ to: vendor.email, subject, template: "vendor-active", context: { vendorName: vendor.vendorName, vendorCode, portalUrl, language: userLang, }, }); } results.push({ id: vendor.id, success: true, vendorCode, message: "Successfully sent to ERP system", }); } catch (vendorError) { // 개별 벤더 처리 오류 기록 results.push({ id: vendor.id, success: false, error: getErrorMessage(vendorError), }); } } // 3. 처리 결과 반환 const successCount = results.filter(r => r.success).length; const failCount = results.filter(r => !r.success).length; return { totalProcessed: results.length, successCount, failCount, results, }; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); return { data: result, error: null }; } catch (err) { console.error("Error sending vendors to ERP:", err); return { data: null, error: getErrorMessage(err) }; } }