"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; 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 { 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, countVendors, insertVendor, updateVendor, updateVendors, groupByStatus, getVendorById, getVendorContactsById, selectVendorContacts, countVendorContacts, insertVendorContact, selectVendorItems, countVendorItems, insertVendorItem, countRfqHistory, selectRfqHistory, selectVendorsWithTypes, countVendorsWithTypes, countVendorMaterials, selectVendorMaterials, insertVendorMaterial, } from "./repository"; import type { CreateVendorSchema, UpdateVendorSchema, GetVendorsSchema, GetVendorContactsSchema, CreateVendorContactSchema, GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, GetVendorMaterialsSchema, } from "./validations"; 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 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, materials } from "@/db/schema/items"; import { 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 { Hospital } from "lucide-react"; /* ----------------------------------------------------- 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) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(vendorsWithTypesView.vendorName, s), ilike(vendorsWithTypesView.vendorCode, s), ilike(vendorsWithTypesView.email, s), ilike(vendorsWithTypesView.status, s), // 추가: 업체 유형 검색 ilike(vendorsWithTypesView.vendorTypeName, s) ); } // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`) : undefined, input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined, input.country ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); // 실제 사용될 where const where = finalWhere; // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) : [asc(vendorsWithTypesView.createdAt)]; // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 (view 사용) const vendorsData = await selectVendorsWithTypes(tx, { where, orderBy, offset, limit: input.perPage, }); // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { 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 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); // 에러 발생 시 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, "PQ_APPROVED": 0, "APPROVED": 0, "READY_TO_SEND": 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 async function getVendorTypes() { unstable_noStore(); // Next.js server action caching prevention try { const types = await db .select({ id: vendorTypes.id, code: vendorTypes.code, nameKo: vendorTypes.nameKo, nameEn: vendorTypes.nameEn, }) .from(vendorTypes) .orderBy(vendorTypes.nameKo); return { data: types, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; } } export type CreateVendorData = { vendorName: string vendorTypeId: number vendorCode?: string items?: string website?: string taxId: string address?: string email: string phone?: string representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string creditAgency?: string creditRating?: string cashFlowRating?: string corporateRegistrationNumber?: string businessSize?: string country?: string status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" } // Updated createVendor function with taxId duplicate check export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] 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)` }; } // taxId 중복 검사 추가 const existingVendor = await db .select({ id: vendors.id }) .from(vendors) .where(eq(vendors.taxId, vendorData.taxId)) .limit(1); // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 if (existingVendor.length > 0) { return { data: null, error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` }; } await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { 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, 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, 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; userId: number; comment:string; } // userId 추가 ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { // 1. 업데이트 전에 기존 벤더 정보를 가져옴 const existingVendor = await tx.query.vendors.findFirst({ where: eq(vendors.id, parseInt(input.id)), columns: { status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴 }, }); if (!existingVendor) { throw new Error(`Vendor with ID ${input.id} not found`); } const oldStatus = existingVendor.status; // 2. 벤더 정보 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, address: input.address, country: input.country, phone: input.phone, email: input.email, website: input.website, creditAgency: input.creditAgency, creditRating: input.creditRating, cashFlowRating: input.cashFlowRating, status: input.status, }); // 3. 상태가 변경되었다면 로그 기록 if (oldStatus !== input.status) { await tx.insert(vendorsLogs).values({ vendorId: parseInt(input.id), userId: input.userId, action: "status_change", oldStatus, newStatus: input.status, comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`, }); } else if (input.comment) { // 상태 변경이 없더라도 코멘트가 있으면 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: parseInt(input.id), userId: input.userId, action: "vendor_updated", comment: input.comment, }); } return res; }); // 필요 시, 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 async function getVendorMaterials(input: GetVendorMaterialsSchema, 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: vendorMaterialsView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorMaterialsView.itemCode, s) , ilike(vendorMaterialsView.description, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorMaterialsView.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(vendorMaterialsView[item.id]) : asc(vendorMaterialsView[item.id]) ) : [asc(vendorMaterialsView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorMaterials(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorMaterials(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-materials-${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) }; } } const updateVendorItemSchema = z.object({ oldItemCode: z.string().min(1, "Old item code is required"), newItemCode: z.string().min(1, "New item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function deleteVendorItem( vendorId: number, itemCode: string ) { try { const validatedData = deleteVendorItemSchema.parse({ itemCode, vendorId, }) await db .delete(vendorPossibleItems) .where( and( eq(vendorPossibleItems.itemCode, validatedData.itemCode), eq(vendorPossibleItems.vendorId, validatedData.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" } } } export async function updateVendorItem( vendorId: number, oldItemCode: string, newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { const validatedData = updateVendorItemSchema.parse({ oldItemCode, newItemCode, vendorId, }) await db.transaction(async (tx) => { // 기존 아이템 삭제 await tx .delete(vendorPossibleItems) .where( and( eq(vendorPossibleItems.itemCode, validatedData.oldItemCode), eq(vendorPossibleItems.vendorId, validatedData.vendorId) ) ) // 새 아이템 추가 await tx.insert(vendorPossibleItems).values({ vendorId: validatedData.vendorId, itemCode: validatedData.newItemCode, }) }) // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) return { data: null, error: getErrorMessage(err) } } } export async function removeVendorItems(input: { itemCodes: string[] vendorId: number }) { unstable_noStore() try { const validatedData = removeVendorItemsSchema.parse(input) await db .delete(vendorPossibleItems) .where( and( inArray(vendorPossibleItems.itemCode, validatedData.itemCodes), eq(vendorPossibleItems.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-items-${validatedData.vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) return { data: null, error: getErrorMessage(err) } } } // 스키마도 추가해야 합니다 const removeVendorItemsSchema = z.object({ itemCodes: z.array(z.string()).min(1, "At least one item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) const deleteVendorItemSchema = z.object({ itemCode: z.string().min(1, "Item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function getMaterialsForVendor(vendorId: number) { return unstable_cache( async () => { try { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db .select({ itemCode: materials.itemCode, itemName: materials.itemName, description: materials.description, }) .from(materials) .leftJoin( vendorPossibleMateirals, eq(materials.itemCode, vendorPossibleMateirals.itemCode) ) // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 .where( isNull(vendorPossibleMateirals.id) // 또는 isNull(vendorPossibleItems.itemCode) ) .orderBy(asc(materials.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 별로 달리 해야 한다. ["materials-for-vendor", String(vendorId)], { revalidate: 3600, // 1시간 캐싱 tags: ["materials"], // revalidateTag("materials") 호출 시 무효화 } )() } export async function createVendorMaterial(input: CreateVendorItemSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorMaterial(tx, { vendorId: input.vendorId, itemCode: input.itemCode, }); return newContact; }); // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-materials-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } const updateVendorMaterialSchema = z.object({ oldItemCode: z.string().min(1, "Old item code is required"), newItemCode: z.string().min(1, "New item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function deleteVendorMaterial( vendorId: number, itemCode: string ) { try { const validatedData = deleteVendorItemSchema.parse({ itemCode, vendorId, }) await db .delete(vendorPossibleMateirals) .where( and( eq(vendorPossibleMateirals.itemCode, validatedData.itemCode), eq(vendorPossibleMateirals.vendorId, validatedData.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" } } } export async function updateVendorMaterial( vendorId: number, oldItemCode: string, newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { const validatedData = updateVendorMaterialSchema.parse({ oldItemCode, newItemCode, vendorId, }) await db.transaction(async (tx) => { // 기존 아이템 삭제 await tx .delete(vendorPossibleMateirals) .where( and( eq(vendorPossibleMateirals.itemCode, validatedData.oldItemCode), eq(vendorPossibleMateirals.vendorId, validatedData.vendorId) ) ) // 새 아이템 추가 await tx.insert(vendorPossibleMateirals).values({ vendorId: validatedData.vendorId, itemCode: validatedData.newItemCode, }) }) // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) return { data: null, error: getErrorMessage(err) } } } export async function removeVendorMaterials(input: { itemCodes: string[] vendorId: number }) { unstable_noStore() try { const validatedData = removeVendormaterialsSchema.parse(input) await db .delete(vendorPossibleMateirals) .where( and( inArray(vendorPossibleMateirals.itemCode, validatedData.itemCodes), eq(vendorPossibleMateirals.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-materials-${validatedData.vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) return { data: null, error: getErrorMessage(err) } } } // 스키마도 추가해야 합니다 const removeVendormaterialsSchema = z.object({ itemCodes: z.array(z.string()).min(1, "At least one item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) 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.query.vendors.findFirst({ where: eq(vendors.taxId, taxID) }); if (result) { // 이미 가입되어 있음 return { success: false, data: result.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 { // API 경로 생성 (단일 파일 또는 모든 파일) const url = fileId ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; // fetch 요청 (기본적으로 Blob으로 응답 받기) const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!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 { url: blobUrl, fileName, blob }; } catch (error) { console.error('Download API error:', error); throw 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[]; projectId?: number | null } /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ 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.query.users.findFirst({ where: eq(users.email, vendor.email), columns: { id: true } }); // 유저가 존재하지 않는 경우에만 생성 if (!existingUser) { // 유저 생성 const [newUser] = await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, companyId: vendor.id, domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정 }).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 } }); // "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; } // userRoles 테이블에 관계 생성 await tx.insert(userRoles).values({ userId: newUser.id, roleId: vendorAdminRole.id, }); } }) ); // 4. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ vendorId: vendorBefore.id, userId: input.userId, action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_REVIEW", comment: "Vendor approved for review", }); }) ); // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Admin Account Created"; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; const baseUrl = `http://${host}` const loginUrl = `${baseUrl}/en/login`; await sendEmail({ to: vendor.email, 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"); // 유저 캐시도 무효화 revalidateTag("roles"); // 역할 캐시도 무효화 revalidateTag("user-roles"); // 유저 역할 캐시도 무효화 return { data: result, error: null }; } catch (err) { console.error("Error approving vendors:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 유니크한 PQ 번호 생성 함수 * * 형식: PQ-YYMMDD-XXXXX * YYMMDD: 연도(YY), 월(MM), 일(DD) * XXXXX: 시퀀스 번호 (00001부터 시작) * * 예: PQ-240520-00001, PQ-240520-00002, ... */ export async function generatePQNumber(isProject: boolean = false) { try { // 현재 날짜 가져오기 const now = new Date(); const year = now.getFullYear().toString().slice(-2); // 년도의 마지막 2자리 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 }) .from(vendorPQSubmissions) .where( sql`${vendorPQSubmissions.pqNumber} LIKE ${datePrefix + '-%'}` ) .orderBy(desc(vendorPQSubmissions.pqNumber)) .limit(1); let sequenceNumber = 1; // 기본값은 1 // 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산 if (latestPQ.length > 0 && latestPQ[0].pqNumber) { const lastPQ = latestPQ[0].pqNumber; const lastSequence = lastPQ.split('-')[2]; if (lastSequence && !isNaN(parseInt(lastSequence))) { 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); // 문제 발생 시 대체 번호 생성 (타임스탬프 기반) const timestamp = Date.now().toString(); const prefix = isProject ? "PPQ" : "PQ"; return `${prefix}-${timestamp}`; } } export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); const session = await getServerSession(authOptions); const requesterId = session?.user?.id ? Number(session.user.id) : null; try { // 프로젝트 정보 가져오기 (projectId가 있는 경우) 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) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ 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. vendorPQSubmissions 테이블에 레코드 추가 (프로젝트 PQ와 일반 PQ 모두) const pqType = input.projectId ? "PROJECT" : "GENERAL"; const currentDate = new Date(); // 기존 PQ 요청이 있는지 확인 (중복 방지) const existingSubmissions = await tx .select({ vendorId: vendorPQSubmissions.vendorId, projectId: vendorPQSubmissions.projectId, type: vendorPQSubmissions.type }) .from(vendorPQSubmissions) .where( and( inArray(vendorPQSubmissions.vendorId, input.ids), eq(vendorPQSubmissions.type, pqType), input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ); // 중복되지 않는 벤더에 대해서만 새 PQ 요청 생성 const existingVendorIds = new Set(existingSubmissions.map(s => s.vendorId)); const newVendorIds = input.ids.filter(id => !existingVendorIds.has(id)); if (newVendorIds.length > 0) { // 각 벤더별로 유니크한 PQ 번호 생성 및 저장 const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { // PQ 번호 생성 (프로젝트 PQ인지 여부 전달) const pqNumber = await generatePQNumber(pqType === "PROJECT"); return { vendorId, pqNumber, // 생성된 PQ 번호 저장 projectId: input.projectId || null, type: pqType, status: "REQUESTED", requesterId: input.userId || requesterId, // 요청자 ID 저장 createdAt: currentDate, updatedAt: currentDate, }; }); // 모든 PQ 번호 생성 완료 대기 const vendorPQData = await Promise.all(vendorPQDataPromises); // 트랜잭션 내에서 데이터 삽입 await tx.insert(vendorPQSubmissions).values(vendorPQData); } // 4. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ vendorId: vendorBefore.id, userId: input.userId, action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_PQ", comment: input.projectId ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` : "General PQ requested", }); }) ); const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 // PQ 번호 조회 (이메일에 포함하기 위해) 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]); // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 const subject = input.projectId ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ''} for ${projectInfo?.projectCode || 'a project'}` : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ''}`; // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; await sendEmail({ to: vendor.email, subject, template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, projectCode: projectInfo?.projectCode || '', projectName: projectInfo?.projectName || '', hasProject: !!input.projectId, pqNumber: vendorPQ?.pqNumber || '', // PQ 번호 추가 }, }); } 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"); 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 { ids: number[]; } /** * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션 */ export async function sendVendors(input: SendVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await db.query.vendors.findMany({ 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 db.query.vendorContacts.findMany({ where: eq(vendorContacts.vendorId, vendor.id) }); // 2-2. 협력업체 가능 아이템 조회 const possibleItems = await db.query.vendorPossibleItems.findMany({ where: eq(vendorPossibleItems.vendorId, vendor.id) }); // 2-3. 협력업체 첨부파일 조회 const attachments = await db.query.vendorAttachments.findMany({ where: eq(vendorAttachments.vendorId, vendor.id), columns: { id: true, fileName: true, filePath: true } }); // 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. 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: input.userId, action: "status_change", oldStatus: "APPROVED", newStatus: "ACTIVE", comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`, }); const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 2-7. 벤더에게 알림 이메일 발송 if (vendor.email) { const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Vendor Registration Completed"; const portalUrl = `http://${host}/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), }); // 에러가 발생해도 로그는 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: input.userId, action: "erp_send_failed", comment: `Failed to send to ERP: ${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) }; } } interface RequestInfoProps { ids: number[]; userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함 } export async function requestInfo({ ids, userId }: RequestInfoProps) { try { return await db.transaction(async (tx) => { // 1. 협력업체 정보 가져오기 const vendorList = await tx.query.vendors.findMany({ where: inArray(vendors.id, ids), }); if (!vendorList.length) { return { error: "협력업체 정보를 찾을 수 없습니다." }; } const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 for (const vendor of vendorList) { // 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: userId, action: "info_requested", comment: "추가 정보 요청됨", }); // 이메일이 없는 경우 스킵 if (!vendor.email) continue; // 협력업체 정보 페이지 URL 생성 const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`; // 벤더에게 이메일 보내기 await sendEmail({ to: vendor.email, subject: "[EVCP] 추가 정보 요청 / Additional Information Request", template: "vendor-additional-info", context: { vendorName: vendor.vendorName, vendorInfoUrl: vendorInfoUrl, language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 }, }); } // 3. 성공적으로 처리됨 return { success: true }; }); } catch (error) { console.error("협력업체 정보 요청 중 오류 발생:", error); return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; } } export async function getVendorDetailById(id: number) { try { // View를 통해 협력업체 정보 조회 const vendor = await db .select() .from(vendorDetailView) .where(eq(vendorDetailView.id, id)) .limit(1) .then(rows => rows[0] || null); if (!vendor) { return null; } // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱 const contacts = typeof vendor.contacts === 'string' ? JSON.parse(vendor.contacts) : vendor.contacts; const attachments = typeof vendor.attachments === 'string' ? JSON.parse(vendor.attachments) : vendor.attachments; // 파싱된 데이터로 반환 return { ...vendor, contacts, attachments }; } catch (error) { console.error("Error fetching vendor detail:", error); throw new Error("Failed to fetch vendor detail"); } } export type UpdateVendorInfoData = { id: number vendorName: string website?: string address?: string email: string phone?: string country?: string representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string corporateRegistrationNumber?: string creditAgency?: string creditRating?: string cashFlowRating?: string } export type ContactInfo = { id?: number contactName: string contactPosition?: string contactEmail: string contactPhone?: string isPrimary?: boolean } /** * 협력업체 정보를 업데이트하는 함수 */ export async function updateVendorInfo(params: { vendorData: UpdateVendorInfoData files?: File[] creditRatingFiles?: File[] cashFlowRatingFiles?: File[] contacts: ContactInfo[] filesToDelete?: number[] // 삭제할 파일 ID 목록 }) { try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts, filesToDelete = [] } = params // 세션 및 권한 확인 const session = await getServerSession(authOptions) if (!session?.user || !session.user.companyId) { return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." }; } const companyId = Number(session.user.companyId); // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능) if ( // !session.user.isAdmin && vendorData.id !== companyId) { return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." }; } // 트랜잭션으로 업데이트 수행 await db.transaction(async (tx) => { // 1. 협력업체 정보 업데이트 await tx.update(vendors).set({ vendorName: vendorData.vendorName, address: vendorData.address || null, email: vendorData.email, phone: vendorData.phone || null, website: vendorData.website || null, country: vendorData.country || 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, updatedAt: new Date(), }).where(eq(vendors.id, vendorData.id)) // 2. 연락처 정보 관리 // 2-1. 기존 연락처 가져오기 const existingContacts = await tx .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorData.id)) // 2-2. 기존 연락처 ID 목록 const existingContactIds = existingContacts.map(c => c.id) // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류 const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id)) const contactsToAdd = contacts.filter(c => !c.id) // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것) const contactIdsToKeep = contactsToUpdate.map(c => c.id) .filter((id): id is number => id !== undefined) const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id)) // 2-5. 연락처 삭제 if (contactIdsToDelete.length > 0) { await tx .delete(vendorContacts) .where(and( eq(vendorContacts.vendorId, vendorData.id), inArray(vendorContacts.id, contactIdsToDelete) )) } // 2-6. 연락처 업데이트 for (const contact of contactsToUpdate) { if (contact.id !== undefined) { await tx .update(vendorContacts) .set({ contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary || false, updatedAt: new Date(), }) .where(and( eq(vendorContacts.id, contact.id), eq(vendorContacts.vendorId, vendorData.id) )) } } // 2-7. 연락처 추가 for (const contact of contactsToAdd) { await tx .insert(vendorContacts) .values({ vendorId: vendorData.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary || false, }) } // 3. 파일 삭제 처리 if (filesToDelete.length > 0) { // 3-1. 삭제할 파일 정보 가져오기 const attachmentsToDelete = await tx .select() .from(vendorAttachments) .where(and( eq(vendorAttachments.vendorId, vendorData.id), inArray(vendorAttachments.id, filesToDelete) )) // 3-2. 파일 시스템에서 파일 삭제 for (const attachment of attachmentsToDelete) { try { // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임 const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, '')) await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인 await fs.unlink(filePath) // 파일 삭제 } catch (error) { console.warn(`Failed to delete file for attachment ${attachment.id}:`, error) // 파일 삭제 실패해도 DB에서는 삭제 진행 } } // 3-3. DB에서 파일 기록 삭제 await tx .delete(vendorAttachments) .where(and( eq(vendorAttachments.vendorId, vendorData.id), inArray(vendorAttachments.id, filesToDelete) )) } // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용) // 4-1. 일반 파일 저장 if (files.length > 0) { await storeVendorFiles(tx, vendorData.id, files, "GENERAL"); } // 4-2. 신용평가 파일 저장 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING"); } // 4-3. 현금흐름 파일 저장 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING"); } }) // 캐시 무효화 revalidateTag("vendors") revalidateTag(`vendor-${vendorData.id}`) return { data: { success: true, message: '협력업체 정보가 성공적으로 업데이트되었습니다.', vendorId: vendorData.id }, error: null } } catch (error) { console.error("Vendor info update error:", error); return { data: null, error: getErrorMessage(error) } } } export interface VendorsLogWithUser { id: number vendorCandidateId: number userId: number userName: string | null userEmail: string | null action: string oldStatus: string | null newStatus: string | null comment: string | null createdAt: Date } export async function getVendorLogs(vendorId: number): Promise { try { const logs = await db .select({ id: vendorsLogs.id, vendorCandidateId: vendorsLogs.vendorId, userId: vendorsLogs.userId, action: vendorsLogs.action, oldStatus: vendorsLogs.oldStatus, newStatus: vendorsLogs.newStatus, comment: vendorsLogs.comment, createdAt: vendorsLogs.createdAt, // 조인한 users 테이블 필드 userName: users.name, userEmail: users.email, }) .from(vendorsLogs) .leftJoin(users, eq(vendorsLogs.userId, users.id)) .where(eq(vendorsLogs.vendorId, vendorId)) .orderBy(desc(vendorsLogs.createdAt)) return logs } catch (error) { console.error("Failed to fetch candidate logs with user info:", error) throw error } } /** * 엑셀 내보내기용 벤더 연락처 목록 조회 * - 페이지네이션 없이 모든 연락처 반환 */ export async function exportVendorContacts(vendorId: number) { try { const contacts = await db .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorId)) .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); return contacts; } catch (error) { console.error("Failed to export vendor contacts:", error); return []; } } /** * 엑셀 내보내기용 벤더 아이템 목록 조회 * - 페이지네이션 없이 모든 아이템 정보 반환 */ export async function exportVendorItems(vendorId: number) { try { const vendorItems = await db .select({ id: vendorItemsView.vendorItemId, vendorId: vendorItemsView.vendorId, itemName: vendorItemsView.itemName, itemCode: vendorItemsView.itemCode, description: vendorItemsView.description, createdAt: vendorItemsView.createdAt, updatedAt: vendorItemsView.updatedAt, }) .from(vendorItemsView) .where(eq(vendorItemsView.vendorId, vendorId)) .orderBy(vendorItemsView.itemName); return vendorItems; } catch (error) { console.error("Failed to export vendor items:", error); return []; } } /** * 엑셀 내보내기용 벤더 RFQ 목록 조회 * - 페이지네이션 없이 모든 RFQ 정보 반환 */ export async function exportVendorRFQs(vendorId: number) { try { const rfqs = await db .select() .from(vendorRfqView) .where(eq(vendorRfqView.vendorId, vendorId)) .orderBy(vendorRfqView.rfqVendorUpdated); return rfqs; } catch (error) { console.error("Failed to export vendor RFQs:", error); return []; } } /** * 엑셀 내보내기용 벤더 계약 목록 조회 * - 페이지네이션 없이 모든 계약 정보 반환 */ export async function exportVendorContracts(vendorId: number) { try { const contracts = await db .select() .from(contractsDetailView) .where(eq(contractsDetailView.vendorId, vendorId)) .orderBy(contractsDetailView.createdAt); return contracts; } catch (error) { console.error("Failed to export vendor contracts:", error); return []; } } /** * 엑셀 내보내기용 벤더 정보 조회 * - 페이지네이션 없이 모든 벤더 정보 반환 */ export async function exportVendorDetails(vendorIds: number[]) { try { if (!vendorIds.length) return []; // 벤더 기본 정보 조회 const vendorsData = await db .select({ id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, taxId: vendors.taxId, address: vendors.address, country: vendors.country, phone: vendors.phone, email: vendors.email, website: vendors.website, status: vendors.status, representativeName: vendors.representativeName, representativeBirth: vendors.representativeBirth, representativeEmail: vendors.representativeEmail, representativePhone: vendors.representativePhone, corporateRegistrationNumber: vendors.corporateRegistrationNumber, creditAgency: vendors.creditAgency, creditRating: vendors.creditRating, cashFlowRating: vendors.cashFlowRating, createdAt: vendors.createdAt, updatedAt: vendors.updatedAt, }) .from(vendors) .where( vendorIds.length === 1 ? eq(vendors.id, vendorIds[0]) : inArray(vendors.id, vendorIds) ); // 벤더별 상세 정보를 포함하여 반환 const vendorsWithDetails = await Promise.all( vendorsData.map(async (vendor) => { // 연락처 조회 const contacts = await exportVendorContacts(vendor.id); // 아이템 조회 const items = await exportVendorItems(vendor.id); // RFQ 조회 const rfqs = await exportVendorRFQs(vendor.id); // 계약 조회 const contracts = await exportVendorContracts(vendor.id); return { ...vendor, vendorContacts: contacts, vendorItems: items, vendorRfqs: rfqs, vendorContracts: contracts, }; }) ); return vendorsWithDetails; } catch (error) { console.error("Failed to export vendor details:", error); return []; } } /** * 벤더 검색 (검색어 기반, 최대 100개) * RFQ 벤더 추가 시 사용 */ export async function searchVendors(searchTerm: string = "", limit: number = 100) { try { let whereCondition; if (searchTerm.trim()) { const s = `%${searchTerm.trim()}%`; whereCondition = or( ilike(vendorsWithTypesView.vendorName, s), ilike(vendorsWithTypesView.vendorCode, s) ); } const vendors = await db .select({ id: vendorsWithTypesView.id, vendorName: vendorsWithTypesView.vendorName, vendorCode: vendorsWithTypesView.vendorCode, status: vendorsWithTypesView.status, country: vendorsWithTypesView.country, }) .from(vendorsWithTypesView) .where( and( whereCondition, // ACTIVE 상태인 벤더만 검색 // eq(vendorsWithTypesView.status, "ACTIVE"), ) ) .orderBy(asc(vendorsWithTypesView.vendorName)) .limit(limit); return vendors; } catch (error) { console.error("벤더 검색 오류:", error); return []; } }