diff options
Diffstat (limited to 'lib/vendors/service.ts')
| -rw-r--r-- | lib/vendors/service.ts | 1345 |
1 files changed, 1345 insertions, 0 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts new file mode 100644 index 00000000..2da16888 --- /dev/null +++ b/lib/vendors/service.ts @@ -0,0 +1,1345 @@ +"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<Vendor["status"], number> = { + 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<Record<Vendor["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Vendor["status"], number>; + } + }, + ["task-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * 신규 Vendor 생성 + */ + +async function storeVendorFiles( + tx: PgTransaction<any, any, any>, + 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<Vendor | null> => { + 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<VendorContact | null> => { + 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) }; + } +} + |
