"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { GetPORfqsSchema, GetQuotationsSchema } from "./validations"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count, between } from "drizzle-orm"; import { incoterms, paymentTerms, prItems, prItemsView, procurementAttachments, procurementQuotationItems, procurementRfqComments, procurementRfqDetails, procurementRfqDetailsView, procurementRfqs, procurementRfqsView, procurementVendorQuotations } from "@/db/schema/procurementRFQ"; import { countPORfqs, selectPORfqs } from "./repository"; import { writeFile, mkdir } from "fs/promises" import { join } from "path" import { v4 as uuidv4 } from "uuid" import { items, projects, users, vendors } from "@/db/schema"; import { formatISO } from "date-fns"; import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; async function getAuthenticatedUser() { const session = await getServerSession(authOptions); if (!session || !session.user?.id) { throw new Error("인증이 필요합니다"); } return { userId: session.user.id, user: session.user }; } export async function getPORfqs(input: GetPORfqsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 기본 필터 처리 - RFQFilterBox에서 오는 필터 const basicFilters = input.basicFilters || []; const basicJoinOperator = input.basicJoinOperator || "and"; // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 const advancedFilters = input.filters || []; const advancedJoinOperator = input.joinOperator || "and"; // 기본 필터 조건 생성 let basicWhere; if (basicFilters.length > 0) { basicWhere = filterColumns({ table: procurementRfqsView, filters: basicFilters, joinOperator: basicJoinOperator, }); } // 고급 필터 조건 생성 let advancedWhere; if (advancedFilters.length > 0) { advancedWhere = filterColumns({ table: procurementRfqsView, filters: advancedFilters, joinOperator: advancedJoinOperator, }); } // 전역 검색 조건 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(procurementRfqsView.rfqCode, s), ilike(procurementRfqsView.projectCode, s), ilike(procurementRfqsView.projectName, s), ilike(procurementRfqsView.dueDate, s), ilike(procurementRfqsView.status, s), // 발주담당 검색 추가 ilike(procurementRfqsView.picCode, s) ); } // 날짜 범위 필터링을 위한 특별 처리 (RFQFilterBox는 이미 basicFilters에 포함) // 이 코드는 기존 처리와의 호환성을 위해 유지 let dateRangeWhere; if (input.filters) { const rfqSendDateFilter = input.filters.find(f => f.id === "rfqSendDate" && Array.isArray(f.value)); if (rfqSendDateFilter && Array.isArray(rfqSendDateFilter.value)) { const [fromDate, toDate] = rfqSendDateFilter.value; if (fromDate && toDate) { // 시작일과 종료일이 모두 있는 경우 dateRangeWhere = between( procurementRfqsView.rfqSendDate, new Date(fromDate), new Date(toDate) ); } else if (fromDate) { // 시작일만 있는 경우 dateRangeWhere = sql`${procurementRfqsView.rfqSendDate} >= ${new Date(fromDate)}`; } } } // 모든 조건 결합 let whereConditions = []; if (basicWhere) whereConditions.push(basicWhere); if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); if (dateRangeWhere) whereConditions.push(dateRangeWhere); // 조건이 있을 때만 and() 사용 const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 정렬 조건 - 안전하게 처리 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(procurementRfqsView[item.id]) : asc(procurementRfqsView[item.id]) ) : [desc(procurementRfqsView.updatedAt)] // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectPORfqs(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countPORfqs(tx, finalWhere); return { data, total }; }); console.log(total) console.log("쿼리 결과 데이터:", data.length); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount ,total }; } catch (err) { console.error("getRfqs 에러:", err); // 에러 세부 정보 더 자세히 로깅 if (err instanceof Error) { console.error("에러 메시지:", err.message); console.error("에러 스택:", err.stack); if ('code' in err) { console.error("SQL 에러 코드:", (err as any).code); } } // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: [`rfqs-po`], } )(); } // RFQ 디테일 데이터를 가져오는 함수 export async function getRfqDetails(rfqId: number) { return unstable_cache( async () => { try { unstable_noStore(); // SQL 쿼리 직접 실행 const data = await db .select() .from(procurementRfqDetailsView) .where(eq(procurementRfqDetailsView.rfqId, rfqId)) console.log(`RFQ 디테일 SQL 조회 완료: ${rfqId}, ${data?.length}건`); return { data }; } catch (err) { console.error("RFQ 디테일 SQL 조회 오류:", err); if (err instanceof Error) { console.error("에러 메시지:", err.message); console.error("에러 스택:", err.stack); } return { data: [] }; } }, [`rfq-details-sql-${rfqId}`], { revalidate: 60, tags: [`rfq-details-${rfqId}`], } )(); } // RFQ ID로 디테일 데이터를 가져오는 서버 액션 export async function fetchRfqDetails(rfqId: number) { "use server"; try { const result = await getRfqDetails(rfqId); return result; } catch (error) { console.error("RFQ 디테일 서버 액션 오류:", error); return { data: [] }; } } // RFQ ID로 PR 상세 항목들을 가져오는 함수 export async function getPrItemsByRfqId(rfqId: number) { return unstable_cache( async () => { try { unstable_noStore(); const data = await db .select() .from(prItemsView) .where(eq(prItemsView.procurementRfqsId, rfqId)) console.log(`PR 항목 조회 완료: ${rfqId}, ${data.length}건`); return { data }; } catch (err) { console.error("PR 항목 조회 오류:", err); if (err instanceof Error) { console.error("에러 메시지:", err.message); console.error("에러 스택:", err.stack); } return { data: [] }; } }, [`pr-items-${rfqId}`], { revalidate: 60, // 1분 캐시 tags: [`pr-items-${rfqId}`], } )(); } // 서버 액션으로 노출할 함수 export async function fetchPrItemsByRfqId(rfqId: number) { "use server"; try { const result = await getPrItemsByRfqId(rfqId); return result; } catch (error) { console.error("PR 항목 서버 액션 오류:", error); return { data: [] }; } } export async function addVendorToRfq(formData: FormData) { try { // 현재 사용자 정보 가져오기 const { userId, user } = await getAuthenticatedUser(); console.log("userId", userId); // rfqId 가져오기 const rfqId = Number(formData.get("rfqId")) if (!rfqId) { return { success: false, message: "RFQ ID가 필요합니다", } } // 폼 데이터 추출 및 기본 검증 (기존과 동일) const vendorId = Number(formData.get("vendorId")) const currency = formData.get("currency") as string const paymentTermsCode = formData.get("paymentTermsCode") as string const incotermsCode = formData.get("incotermsCode") as string const incotermsDetail = formData.get("incotermsDetail") as string || null const deliveryDate = formData.get("deliveryDate") ? new Date(formData.get("deliveryDate") as string) : null const taxCode = formData.get("taxCode") as string || null const placeOfShipping = formData.get("placeOfShipping") as string || null const placeOfDestination = formData.get("placeOfDestination") as string || null const materialPriceRelatedYn = formData.get("materialPriceRelatedYn") === "true" if (!vendorId || !currency || !paymentTermsCode || !incotermsCode) { return { success: false, message: "필수 항목이 누락되었습니다", } } // 트랜잭션 시작 return await db.transaction(async (tx) => { // 0. 먼저 RFQ 상태 확인 const rfq = await tx.query.procurementRfqs.findFirst({ where: eq(procurementRfqs.id, rfqId), columns: { id: true, status: true } }); if (!rfq) { throw new Error("RFQ를 찾을 수 없습니다"); } console.log("rfq.status", rfq.status); // 1. RFQ 상세 정보 저장 const insertedDetails = await tx.insert(procurementRfqDetails).values({ procurementRfqsId: rfqId, vendorsId: vendorId, currency, paymentTermsCode, incotermsCode, incotermsDetail, deliveryDate: deliveryDate || new Date(), // null이면 현재 날짜 사용 taxCode, placeOfShipping, placeOfDestination, materialPriceRelatedYn, updatedBy: Number(userId), updatedAt: new Date(), }).returning({ id: procurementRfqDetails.id }); if (!insertedDetails || insertedDetails.length === 0) { throw new Error("RFQ 상세 정보 저장에 실패했습니다"); } const detailId = insertedDetails[0].id; // 2. RFQ 상태가 "RFQ Created"인 경우 "RFQ Vendor Assignned"로 업데이트 let statusUpdated = false; if (rfq.status === "RFQ Created") { console.log("rfq 상태 업데이트 시작") await tx.update(procurementRfqs) .set({ status: "RFQ Vendor Assignned", updatedBy: Number(userId), updatedAt: new Date() }) .where(eq(procurementRfqs.id, rfqId)); statusUpdated = true; } // 3. 첨부 파일 처리 const filePromises = []; const uploadDir = join(process.cwd(), "public", "rfq", rfqId.toString(), "vendors", detailId.toString()); // 업로드 디렉토리 생성 try { await mkdir(uploadDir, { recursive: true }); } catch (error) { console.error("디렉토리 생성 오류:", error); } // FormData에서 file 타입 항목 찾기 for (const [key, value] of formData.entries()) { if (key.startsWith("attachment-") && value instanceof File) { const file = value as File; // 파일 크기가 0이면 건너뛰기 if (file.size === 0) continue; // 파일 이름 생성 const uniqueId = uuidv4(); const fileName = `${uniqueId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`; const filePath = join(uploadDir, fileName); // 파일을 버퍼로 변환 const buffer = Buffer.from(await file.arrayBuffer()); // 파일 저장 await writeFile(filePath, buffer); // DB에 첨부 파일 정보 저장 filePromises.push( tx.insert(procurementAttachments).values({ attachmentType: 'VENDOR_SPECIFIC', procurementRfqsId: null, procurementRfqDetailsId: detailId, fileName: fileName, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/vendors/${detailId}/${fileName}`, fileSize: file.size, fileType: file.type, description: `${file.name} - 벤더 ID ${vendorId}용 첨부파일`, createdBy: Number(userId), createdAt: new Date(), }) ); } } // 첨부 파일이 있으면 처리 if (filePromises.length > 0) { await Promise.all(filePromises); } // 캐시 무효화 (여러 경로 지정 가능) revalidateTag(`rfq-details-${rfqId}`); revalidateTag(`rfqs-po`); return { success: true, message: "벤더 정보가 성공적으로 추가되었습니다", data: { id: detailId, statusUpdated: statusUpdated }, }; }); } catch (error) { console.error("벤더 추가 오류:", error); return { success: false, message: "벤더 추가 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)), }; } } // 벤더 데이터 조회 서버 액션 export async function fetchVendors() { try { const data = await db.select().from(vendors) return { success: true, data, } } catch (error) { console.error("벤더 데이터 로드 오류:", error) return { success: false, message: "벤더 데이터를 불러오는 데 실패했습니다", data: [] } } } // 통화 데이터 조회 서버 액션 export async function fetchCurrencies() { try { // 통화 테이블이 별도로 없다면 여기서 하드코딩하거나 설정 파일에서 가져올 수도 있습니다 const data = [ { code: "KRW", name: "Korean Won" }, { code: "USD", name: "US Dollar" }, { code: "EUR", name: "Euro" }, { code: "JPY", name: "Japanese Yen" }, { code: "CNY", name: "Chinese Yuan" }, ] return { success: true, data, } } catch (error) { console.error("통화 데이터 로드 오류:", error) return { success: false, message: "통화 데이터를 불러오는 데 실패했습니다", data: [] } } } // 지불 조건 데이터 조회 서버 액션 export async function fetchPaymentTerms() { try { const data = await db.select().from(paymentTerms) return { success: true, data, } } catch (error) { console.error("지불 조건 데이터 로드 오류:", error) return { success: false, message: "지불 조건 데이터를 불러오는 데 실패했습니다", data: [] } } } // 인코텀즈 데이터 조회 서버 액션 export async function fetchIncoterms() { try { const data = await db.select().from(incoterms) return { success: true, data, } } catch (error) { console.error("인코텀즈 데이터 로드 오류:", error) return { success: false, message: "인코텀즈 데이터를 불러오는 데 실패했습니다", data: [] } } } export async function deleteRfqDetail(detailId: number) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session || !session.user) { return { success: false, message: "인증이 필요합니다", }; } // DB에서 항목 삭제 await db.delete(procurementRfqDetails) .where(eq(procurementRfqDetails.id, detailId)); // 캐시 무효화 revalidateTag(`rfq-details-${detailId}`); return { success: true, message: "RFQ 벤더 정보가 삭제되었습니다", }; } catch (error) { console.error("RFQ 벤더 정보 삭제 오류:", error); return { success: false, message: "RFQ 벤더 정보 삭제 중 오류가 발생했습니다", }; } } // RFQ 상세 정보 수정 export async function updateRfqDetail(detailId: number, data: any) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session || !session.user) { return { success: false, message: "인증이 필요합니다", }; } const userId = Number(session.user.id); // 필요한 데이터 추출 const { vendorId, currency, paymentTermsCode, incotermsCode, incotermsDetail, deliveryDate, taxCode, placeOfShipping, placeOfDestination, materialPriceRelatedYn, } = data; // DB 업데이트 await db.update(procurementRfqDetails) .set({ vendorsId: Number(vendorId), currency, paymentTermsCode, incotermsCode, incotermsDetail: incotermsDetail || null, deliveryDate: deliveryDate ? new Date(deliveryDate) : new Date(), taxCode: taxCode || null, placeOfShipping: placeOfShipping || null, placeOfDestination: placeOfDestination || null, materialPriceRelatedYn, updatedBy: userId, updatedAt: new Date(), }) .where(eq(procurementRfqDetails.id, detailId)); // 캐시 무효화 revalidateTag(`rfq-details-${detailId}`); return { success: true, message: "RFQ 벤더 정보가 수정되었습니다", }; } catch (error) { console.error("RFQ 벤더 정보 수정 오류:", error); return { success: false, message: "RFQ 벤더 정보 수정 중 오류가 발생했습니다", }; } } export async function updateRfqRemark(rfqId: number, remark: string) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session || !session.user) { return { success: false, message: "인증이 필요합니다", }; } console.log(rfqId, remark) // DB 업데이트 await db.update(procurementRfqs) .set({ remark, updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(procurementRfqs.id, rfqId)); // 캐시 무효화 revalidateTag(`rfqs-po`); revalidatePath("/evcp/po-rfq"); // 경로도 함께 무효화 return { success: true, message: "비고가 업데이트되었습니다", }; } catch (error) { console.error("비고 업데이트 오류:", error); return { success: false, message: "비고 업데이트 중 오류가 발생했습니다", }; } } export async function sealRfq(rfqId: number) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session || !session.user) { return { success: false, message: "인증이 필요합니다", }; } // DB 업데이트 await db.update(procurementRfqs) .set({ rfqSealedYn: true, updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(procurementRfqs.id, rfqId)); // 캐시 무효화 revalidateTag(`rfqs-po`); return { success: true, message: "RFQ가 성공적으로 밀봉되었습니다", }; } catch (error) { console.error("RFQ 밀봉 오류:", error); return { success: false, message: "RFQ 밀봉 중 오류가 발생했습니다", }; } } // RFQ 전송 서버 액션 export async function sendRfq(rfqId: number) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, message: "인증이 필요합니다", } } // 현재 RFQ 상태 확인 // RFQ 및 관련 정보 조회 const rfq = await db.query.procurementRfqs.findFirst({ where: eq(procurementRfqs.id, rfqId), columns: { id: true, rfqCode: true, status: true, dueDate: true, rfqSendDate: true, remark: true, rfqSealedYn: true, itemCode: true, itemName: true, }, with: { project: { columns: { id: true, code: true, name: true, } }, createdByUser: { columns: { id: true, name: true, email: true, } }, prItems: { columns: { id: true, rfqItem: true, // 아이템 번호 materialCode: true, materialDescription: true, quantity: true, uom: true, prNo: true, majorYn: true, } } } }); if (!rfq) { return { success: false, message: "RFQ를 찾을 수 없습니다", } } if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { return { success: false, message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", } } const isResend = rfq.status === "RFQ Sent"; // 현재 사용자 정보 조회 (CC 용) const sender = await db.query.users.findFirst({ where: eq(users.id, Number(session.user.id)), columns: { id: true, email: true, name: true, } }); if (!sender || !sender.email) { return { success: false, message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", } } // RFQ에 할당된 벤더 목록 조회 const rfqDetails = await db.query.procurementRfqDetails.findMany({ where: eq(procurementRfqDetails.procurementRfqsId, rfqId), columns: { id: true, vendorsId: true, currency: true, paymentTermsCode: true, incotermsCode: true, incotermsDetail: true, deliveryDate: true, }, with: { vendor: { columns: { id: true, vendorName: true, vendorCode: true, } }, paymentTerms: { columns: { code: true, description: true, } }, incoterms: { columns: { code: true, description: true, } } } }); if (rfqDetails.length === 0) { return { success: false, message: "할당된 벤더가 없습니다", } } // 트랜잭션 시작 await db.transaction(async (tx) => { // 1. RFQ 상태 업데이트 await tx.update(procurementRfqs) .set({ status: "RFQ Sent", rfqSendDate: new Date(), updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(procurementRfqs.id, rfqId)); // 2. 각 벤더에 대해 초기 견적서 레코드 생성 및 이메일 발송 for (const detail of rfqDetails) { if (!detail.vendorsId || !detail.vendor) continue; // 기존 Draft 견적서가 있는지 확인 const existingQuotation = await tx.query.procurementVendorQuotations.findFirst({ where: and( eq(procurementVendorQuotations.rfqId, rfqId), eq(procurementVendorQuotations.vendorId, detail.vendorsId) ), orderBy: [desc(procurementVendorQuotations.quotationVersion)] }); // 견적서 코드 (기존 것 재사용 또는 신규 생성) const quotationCode = existingQuotation?.quotationCode || `${rfq.rfqCode}-${detail.vendorsId}`; // 버전 관리 - 재전송인 경우 버전 증가 const quotationVersion = existingQuotation ? ((existingQuotation.quotationVersion? existingQuotation.quotationVersion: 0 )+ 1) : 1; // 견적서 레코드 생성 const insertedQuotation = await tx.insert(procurementVendorQuotations).values({ rfqId, vendorId: detail.vendorsId, quotationCode, quotationVersion, totalItemsCount: rfq.prItems.length, subTotal: "0", taxTotal: "0", discountTotal: "0", totalPrice: "0", currency: detail.currency || "USD", // 납품일은 RFQ 납품일보다 조금 이전으로 설정 (기본값) estimatedDeliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate.getTime() - 7 * 24 * 60 * 60 * 1000) : // 1주일 전 undefined, paymentTermsCode: detail.paymentTermsCode, incotermsCode: detail.incotermsCode, incotermsDetail: detail.incotermsDetail, status: "Draft", createdBy: Number(session.user.id), updatedBy: Number(session.user.id), createdAt: new Date(), updatedAt: new Date(), }).returning({ id: procurementVendorQuotations.id }); // 새로 생성된 견적서 ID const quotationId = insertedQuotation[0].id; // 3. 각 PR 아이템에 대해 견적 아이템 생성 for (const prItem of rfq.prItems) { // procurementQuotationItems에 레코드 생성 await tx.insert(procurementQuotationItems).values({ quotationId, prItemId: prItem.id, materialCode: prItem.materialCode, materialDescription: prItem.materialDescription, quantity: prItem.quantity, uom: prItem.uom, // 기본값으로 설정된 필드 unitPrice: 0, totalPrice: 0, currency: detail.currency || "USD", // 나머지 필드는 null 또는 기본값 사용 createdAt: new Date(), updatedAt: new Date(), }); } // 벤더에 속한 모든 사용자 조회 const vendorUsers = await db.query.users.findMany({ where: eq(users.companyId, detail.vendorsId), columns: { id: true, email: true, name: true, language: true } }); // 유효한 이메일 주소만 필터링 const vendorEmailsString = vendorUsers .filter(user => user.email) .map(user => user.email) .join(", "); if (vendorEmailsString) { // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) const language = vendorUsers[0]?.language || "en"; // 이메일 컨텍스트 구성 const emailContext = { language: language, rfq: { id: rfq.id, code: rfq.rfqCode, title: rfq.item?.itemName || '', projectCode: rfq.project?.code || '', projectName: rfq.project?.name || '', description: rfq.remark || '', dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A', deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate, "KR") : 'N/A', }, vendor: { id: detail.vendor.id, code: detail.vendor.vendorCode || '', name: detail.vendor.vendorName, }, sender: { fullName: sender.name || '', email: sender.email, }, items: rfq.prItems.map(item => ({ itemNumber: item.rfqItem || '', materialCode: item.materialCode || '', description: item.materialDescription || '', quantity: item.quantity, uom: item.uom || '', })), details: { currency: detail.currency || 'USD', paymentTerms: detail.paymentTerms?.description || detail.paymentTermsCode || 'N/A', incoterms: detail.incoterms ? `${detail.incoterms.code} ${detail.incotermsDetail || ''}` : detail.incotermsCode ? `${detail.incotermsCode} ${detail.incotermsDetail || ''}` : 'N/A', }, quotationCode: existingQuotation?.quotationCode || `QUO-${rfqId}-${detail.vendorsId}`, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', isResend: isResend, quotationVersion: quotationVersion, versionInfo: isResend ? `(버전 ${quotationVersion})` : '', }; // 이메일 전송 (모든 벤더 이메일을 to 필드에 배열로 전달) await sendEmail({ to: vendorEmailsString, subject: isResend ? `[RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}` : `[RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`, template: 'rfq-notification', context: emailContext, cc: sender.email, // 발신자를 CC에 추가 }); } } }); // 캐시 무효화 revalidateTag(`rfqs-po`); return { success: true, message: "RFQ가 성공적으로 전송되었습니다", } } catch (error) { console.error("RFQ 전송 오류:", error); return { success: false, message: "RFQ 전송 중 오류가 발생했습니다", } } } /** * 첨부파일 타입 정의 */ export interface Attachment { id: number fileName: string fileSize: number fileType: string | null // <- null 허용 filePath: string uploadedAt: Date } /** * 코멘트 타입 정의 */ export interface Comment { id: number rfqId: number vendorId: number | null // null 허용으로 변경 userId?: number | null // null 허용으로 변경 content: string isVendorComment: boolean | null // null 허용으로 변경 createdAt: Date updatedAt: Date userName?: string | null // null 허용으로 변경 vendorName?: string | null // null 허용으로 변경 attachments: Attachment[] isRead: boolean | null // null 허용으로 변경 } /** * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 * * @param rfqId RFQ ID * @param vendorId 벤더 ID * @returns 코멘트 목록 */ export async function fetchVendorComments(rfqId: number, vendorId?: number): Promise { if (!vendorId) { return [] } try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다") } // 코멘트 쿼리 const comments = await db.query.procurementRfqComments.findMany({ where: and( eq(procurementRfqComments.rfqId, rfqId), eq(procurementRfqComments.vendorId, vendorId) ), orderBy: [procurementRfqComments.createdAt], with: { user: { columns: { name: true } }, vendor: { columns: { vendorName: true } }, attachments: true, } }) // 결과 매핑 return comments.map(comment => ({ id: comment.id, rfqId: comment.rfqId, vendorId: comment.vendorId, userId: comment.userId || undefined, content: comment.content, isVendorComment: comment.isVendorComment, createdAt: comment.createdAt, updatedAt: comment.updatedAt, userName: comment.user?.name, vendorName: comment.vendor?.vendorName, isRead: comment.isRead, attachments: comment.attachments.map(att => ({ id: att.id, fileName: att.fileName, fileSize: att.fileSize, fileType: att.fileType, filePath: att.filePath, uploadedAt: att.uploadedAt })) })) } catch (error) { console.error('벤더 코멘트 가져오기 오류:', error) throw error } } /** * 코멘트를 읽음 상태로 표시하는 서버 액션 * * @param rfqId RFQ ID * @param vendorId 벤더 ID */ export async function markMessagesAsRead(rfqId: number, vendorId?: number): Promise { if (!vendorId) { return } try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다") } // 벤더가 작성한 읽지 않은 코멘트 업데이트 await db.update(procurementRfqComments) .set({ isRead: true }) .where( and( eq(procurementRfqComments.rfqId, rfqId), eq(procurementRfqComments.vendorId, vendorId), eq(procurementRfqComments.isVendorComment, true), eq(procurementRfqComments.isRead, false) ) ) // 캐시 무효화 revalidateTag(`rfq-${rfqId}-comments`) } catch (error) { console.error('메시지 읽음 표시 오류:', error) throw error } } /** * 읽지 않은 메시지 개수 가져오기 서버 액션 * * @param rfqId RFQ ID */ export async function fetchUnreadMessages(rfqId: number): Promise> { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다"); } // 쿼리 빌더 방식으로 카운트 조회 - 타입 안전 방식 const result = await db .select({ vendorId: procurementRfqComments.vendorId, unreadCount: count() }) .from(procurementRfqComments) .where( and( eq(procurementRfqComments.rfqId, rfqId), eq(procurementRfqComments.isVendorComment, true), eq(procurementRfqComments.isRead, false) ) ) .groupBy(procurementRfqComments.vendorId); // 결과 매핑 const unreadMessages: Record = {}; result.forEach(row => { if (row.vendorId) { unreadMessages[row.vendorId] = Number(row.unreadCount); } }); return unreadMessages; } catch (error) { console.error('읽지 않은 메시지 개수 가져오기 오류:', error); throw error; } } /** * 견적서 업데이트 서버 액션 */ export async function updateVendorQuotation(data: { id: number quotationVersion?: number currency?: string validUntil?: Date estimatedDeliveryDate?: Date paymentTermsCode?: string incotermsCode?: string incotermsDetail?: string remark?: string subTotal?: string taxTotal?: string discountTotal?: string totalPrice?: string totalItemsCount?: number }) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, message: "인증이 필요합니다", } } // 견적서 존재 확인 const quotation = await db.query.procurementVendorQuotations.findFirst({ where: eq(procurementVendorQuotations.id, data.id), }) if (!quotation) { return { success: false, message: "견적서를 찾을 수 없습니다", } } // 권한 확인 (벤더 또는 관리자만 수정 가능) const isAuthorized = (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) if (!isAuthorized) { return { success: false, message: "견적서 수정 권한이 없습니다", } } // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) if (quotation.status !== "Draft" && quotation.status !== "Rejected") { return { success: false, message: "제출되었거나 승인된 견적서는 수정할 수 없습니다", } } // 업데이트할 데이터 구성 const updateData: Record = { updatedBy: Number(session.user.id), updatedAt: new Date(), } // 필드 추가 if (data.currency) updateData.currency = data.currency if (data.validUntil) updateData.validUntil = data.validUntil if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail if (data.remark !== undefined) updateData.remark = data.remark if (data.subTotal) updateData.subTotal = data.subTotal if (data.taxTotal) updateData.taxTotal = data.taxTotal if (data.discountTotal) updateData.discountTotal = data.discountTotal if (data.totalPrice) updateData.totalPrice = data.totalPrice if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount // Rejected 상태에서 수정 시 Draft 상태로 변경 if (quotation.status === "Rejected") { updateData.status = "Draft" // 버전 증가 if (data.quotationVersion) { updateData.quotationVersion = data.quotationVersion + 1 } else { updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 } } // 견적서 업데이트 await db.update(procurementVendorQuotations) .set(updateData) .where(eq(procurementVendorQuotations.id, data.id)) // 캐시 무효화 revalidateTag(`quotation-${data.id}`) revalidateTag(`rfq-${quotation.rfqId}`) return { success: true, message: "견적서가 업데이트되었습니다", } } catch (error) { console.error("견적서 업데이트 오류:", error) return { success: false, message: "견적서 업데이트 중 오류가 발생했습니다", } } } interface QuotationItem { unitPrice: number; deliveryDate: Date | null; status: "Draft" | "Rejected" | "Submitted" | "Approved"; // 상태를 유니온 타입으로 정의 // 필요한 다른 속성들도 추가 } /** * 견적서 제출 서버 액션 */ export async function submitVendorQuotation(data: { id: number quotationVersion?: number currency?: string validUntil?: Date estimatedDeliveryDate?: Date paymentTermsCode?: string incotermsCode?: string incotermsDetail?: string remark?: string subTotal?: string taxTotal?: string discountTotal?: string totalPrice?: string totalItemsCount?: number }) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, message: "인증이 필요합니다", } } // 견적서 존재 확인 const quotation = await db.query.procurementVendorQuotations.findFirst({ where: eq(procurementVendorQuotations.id, data.id), with: { items: true, } }) if (!quotation) { return { success: false, message: "견적서를 찾을 수 없습니다", } } // 권한 확인 (벤더 또는 관리자만 제출 가능) const isAuthorized = (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) if (!isAuthorized) { return { success: false, message: "견적서 제출 권한이 없습니다", } } // 상태 확인 (Draft 또는 Rejected 상태만 제출 가능) if (quotation.status !== "Draft" && quotation.status !== "Rejected") { return { success: false, message: "이미 제출되었거나 승인된 견적서는 다시 제출할 수 없습니다", } } // 견적 항목 검증 if (!quotation.items || (quotation.items as QuotationItem[]).length === 0) { return { success: false, message: "견적 항목이 없습니다", } } // 필수 항목 검증 const hasEmptyItems = (quotation.items as QuotationItem[]).some(item => item.unitPrice <= 0 || !item.deliveryDate ) if (hasEmptyItems) { return { success: false, message: "모든 항목의 단가와 납품일을 입력해주세요", } } // 필수 정보 검증 if (!data.validUntil || !data.estimatedDeliveryDate) { return { success: false, message: "견적 유효기간과 예상 납품일은 필수 항목입니다", } } // 업데이트할 데이터 구성 const updateData: Record = { status: "Submitted", submittedAt: new Date(), updatedBy: Number(session.user.id), updatedAt: new Date(), } // 필드 추가 if (data.currency) updateData.currency = data.currency if (data.validUntil) updateData.validUntil = data.validUntil if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail if (data.remark !== undefined) updateData.remark = data.remark if (data.subTotal) updateData.subTotal = data.subTotal if (data.taxTotal) updateData.taxTotal = data.taxTotal if (data.discountTotal) updateData.discountTotal = data.discountTotal if (data.totalPrice) updateData.totalPrice = data.totalPrice if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount // Rejected 상태에서 제출 시 버전 증가 if (quotation.status === "Rejected") { updateData.status = "Revised" if (data.quotationVersion) { updateData.quotationVersion = data.quotationVersion + 1 } else { updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 } } // 견적서 업데이트 await db.update(procurementVendorQuotations) .set(updateData) .where(eq(procurementVendorQuotations.id, data.id)) // 캐시 무효화 revalidateTag(`quotation-${data.id}`) revalidateTag(`rfq-${quotation.rfqId}`) return { success: true, message: "견적서가 성공적으로 제출되었습니다", } } catch (error) { console.error("견적서 제출 오류:", error) return { success: false, message: "견적서 제출 중 오류가 발생했습니다", } } } /** * 견적 항목 업데이트 서버 액션 */ export async function updateQuotationItem(data: { id: number unitPrice?: number totalPrice?: number vendorMaterialCode?: string vendorMaterialDescription?: string deliveryDate?: Date | null leadTimeInDays?: number taxRate?: number taxAmount?: number discountRate?: number discountAmount?: number remark?: string isAlternative?: boolean isRecommended?: boolean }) { try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, message: "인증이 필요합니다", } } // 항목 존재 확인 const item = await db.query.procurementQuotationItems.findFirst({ where: eq(procurementQuotationItems.id, data.id), with: { quotation: true, } }) if (!item || !item.quotation) { return { success: false, message: "견적 항목을 찾을 수 없습니다", } } // 권한 확인 (벤더 또는 관리자만 수정 가능) const isAuthorized = ( session.user.domain === "partners" && session.user.companyId === (item.quotation as { vendorId: number }).vendorId ) if (!isAuthorized) { return { success: false, message: "견적 항목 수정 권한이 없습니다", } } const quotation = item.quotation as Quotation; // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) if (quotation.status !== "Draft" && quotation.status !== "Rejected") { return { success: false, message: "제출되었거나 승인된 견적서의 항목은 수정할 수 없습니다", } } // 업데이트할 데이터 구성 const updateData: Record = { updatedAt: new Date(), } // 필드 추가 if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice if (data.totalPrice !== undefined) updateData.totalPrice = data.totalPrice if (data.vendorMaterialCode !== undefined) updateData.vendorMaterialCode = data.vendorMaterialCode if (data.vendorMaterialDescription !== undefined) updateData.vendorMaterialDescription = data.vendorMaterialDescription if (data.deliveryDate !== undefined) updateData.deliveryDate = data.deliveryDate if (data.leadTimeInDays !== undefined) updateData.leadTimeInDays = data.leadTimeInDays if (data.taxRate !== undefined) updateData.taxRate = data.taxRate if (data.taxAmount !== undefined) updateData.taxAmount = data.taxAmount if (data.discountRate !== undefined) updateData.discountRate = data.discountRate if (data.discountAmount !== undefined) updateData.discountAmount = data.discountAmount if (data.remark !== undefined) updateData.remark = data.remark if (data.isAlternative !== undefined) updateData.isAlternative = data.isAlternative if (data.isRecommended !== undefined) updateData.isRecommended = data.isRecommended // 항목 업데이트 await db.update(procurementQuotationItems) .set(updateData) .where(eq(procurementQuotationItems.id, data.id)) // 캐시 무효화 revalidateTag(`quotation-${item.quotationId}`) return { success: true, message: "견적 항목이 업데이트되었습니다", } } catch (error) { console.error("견적 항목 업데이트 오류:", error) return { success: false, message: "견적 항목 업데이트 중 오류가 발생했습니다", } } } // Quotation 상태 타입 정의 export type QuotationStatus = "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"; // 인터페이스 정의 export interface Quotation { id: number; quotationCode: string; status: QuotationStatus; totalPrice: string; currency: string; submittedAt: string | null; validUntil: string | null; vendorId: number; rfq?: { rfqCode: string; } | null; vendor?: any; } /** * 벤더별 견적서 목록 조회 */ export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: string) { return unstable_cache( async () => { try { // 페이지네이션 설정 const page = input.page || 1; const perPage = input.perPage || 10; const offset = (page - 1) * perPage; // 필터링 설정 // advancedTable 모드로 where 절 구성 const advancedWhere = filterColumns({ table: procurementVendorQuotations, filters: input.filters, joinOperator: input.joinOperator, }); // 글로벌 검색 조건 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(procurementVendorQuotations.quotationCode, s), ilike(procurementVendorQuotations.status, s), ilike(procurementVendorQuotations.totalPrice, s) ); } // 벤더 ID 조건 const vendorIdWhere = vendorId ? eq(procurementVendorQuotations.vendorId, Number(vendorId)) : undefined; // 모든 조건 결합 let whereConditions = []; if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); if (vendorIdWhere) whereConditions.push(vendorIdWhere); // 최종 조건 const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 정렬 설정 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => { // @ts-ignore - 동적 속성 접근 return item.desc ? desc(procurementVendorQuotations[item.id]) : asc(procurementVendorQuotations[item.id]); }) : [asc(procurementVendorQuotations.updatedAt)]; // 쿼리 실행 const quotations = await db.query.procurementVendorQuotations.findMany({ where: finalWhere, orderBy, offset, limit: perPage, with: { rfq:true, vendor: true, } }); // 전체 개수 조회 const { totalCount } = await db .select({ totalCount: count() }) .from(procurementVendorQuotations) .where(finalWhere || undefined) .then(rows => rows[0]); // 페이지 수 계산 const pageCount = Math.ceil(Number(totalCount) / perPage); return { data: quotations as Quotation[], pageCount }; } catch (err) { console.error("getVendorQuotations 에러:", err); return { data: [], pageCount: 0 }; } }, [`vendor-quotations-${vendorId}-${JSON.stringify(input)}`], { revalidate: 3600, tags: [`vendor-quotations-${vendorId}`], } )(); } /** * 견적서 상태별 개수 조회 */ export async function getQuotationStatusCounts(vendorId: string) { return unstable_cache( async () => { try { const initial: Record = { Draft: 0, Submitted: 0, Revised: 0, Rejected: 0, Accepted: 0, }; // 벤더 ID 조건 const whereCondition = vendorId ? eq(procurementVendorQuotations.vendorId, Number(vendorId)) : undefined; // 상태별 그룹핑 쿼리 const rows = await db .select({ status: procurementVendorQuotations.status, count: count(), }) .from(procurementVendorQuotations) .where(whereCondition) .groupBy(procurementVendorQuotations.status); // 결과 처리 const result = rows.reduce>((acc, { status, count }) => { if (status) { acc[status as QuotationStatus] = Number(count); } return acc; }, initial); return result; } catch (err) { console.error("getQuotationStatusCounts 에러:", err); return {} as Record; } }, [`quotation-status-counts-${vendorId}`], { revalidate: 3600, } )(); } /** * 벤더 입장에서 구매자와의 커뮤니케이션 메시지를 가져오는 서버 액션 * * @param rfqId RFQ ID * @param vendorId 벤더 ID * @returns 코멘트 목록 */ export async function fetchBuyerVendorComments(rfqId: number, vendorId: number): Promise { if (!rfqId || !vendorId) { return []; } try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다"); } // 벤더 접근 권한 확인 (벤더 사용자이며 해당 벤더의 ID와 일치해야 함) if ( session.user.domain === "partners" && ((session.user.companyId ?? 0) !== Number(vendorId)) ) { throw new Error("접근 권한이 없습니다"); } // 코멘트 쿼리 const comments = await db.query.procurementRfqComments.findMany({ where: and( eq(procurementRfqComments.rfqId, rfqId), eq(procurementRfqComments.vendorId, vendorId) ), orderBy: [procurementRfqComments.createdAt], with: { user: { columns: { name: true } }, vendor: { columns: { vendorName: true } }, attachments: true, } }); // 벤더가 접근하는 경우, 벤더 메시지를 읽음 상태로 표시 if (session.user.domain === "partners") { // 읽지 않은 구매자 메시지를 읽음 상태로 업데이트 await db.update(procurementRfqComments) .set({ isRead: true }) .where( and( eq(procurementRfqComments.rfqId, rfqId), eq(procurementRfqComments.vendorId, vendorId), eq(procurementRfqComments.isVendorComment, false), // 구매자가 보낸 메시지 eq(procurementRfqComments.isRead, false) ) ) .execute(); } // 결과 매핑 return comments.map(comment => ({ id: comment.id, rfqId: comment.rfqId, vendorId: comment.vendorId, userId: comment.userId || undefined, content: comment.content, isVendorComment: comment.isVendorComment, createdAt: comment.createdAt, updatedAt: comment.updatedAt, userName: comment.user?.name, vendorName: comment.vendor?.vendorName, isRead: comment.isRead, attachments: comment.attachments.map(att => ({ id: att.id, fileName: att.fileName, fileSize: att.fileSize, fileType: att.fileType, filePath: att.filePath, uploadedAt: att.uploadedAt })) })); } catch (error) { console.error('벤더-구매자 커뮤니케이션 가져오기 오류:', error); throw error; } } const getRandomProject = async () => { const allProjects = await db.select().from(projects).limit(10); const randomIndex = Math.floor(Math.random() * allProjects.length); return allProjects[randomIndex] || null; }; const getRandomItem = async () => { const allItems = await db.select().from(items).limit(10); const randomIndex = Math.floor(Math.random() * allItems.length); return allItems[randomIndex] || null; }; // 외부 시스템에서 RFQ 가져오기 서버 액션 export async function fetchExternalRfqs() { try { // 현재 로그인한 사용자의 세션 정보 가져오기 const session = await getServerSession(authOptions); if (!session || !session.user || !session.user.id) { return { success: false, message: '인증된 사용자 정보를 찾을 수 없습니다' }; } const userId = session.user.id; const randomProject = await getRandomProject(); const randomItem = await getRandomItem(); if (!randomProject || !randomItem) { return { success: false, message: '임의 데이터를 생성하는 데 필요한 기본 데이터가 없습니다' }; } // 현재 날짜 기준 임의 날짜 생성 const today = new Date(); const dueDate = new Date(today); dueDate.setDate(today.getDate() + Math.floor(Math.random() * 30) + 15); // 15-45일 후 // RFQ 코드 생성 (현재 연도 + 3자리 숫자) const currentYear = today.getFullYear(); const randomNum = Math.floor(Math.random() * 900) + 100; // 100-999 const rfqCode = `R${currentYear}${randomNum}`; const seriesOptions = ["SS", "II", ""]; const randomSeriesIndex = Math.floor(Math.random() * seriesOptions.length); const seriesValue = seriesOptions[randomSeriesIndex]; // RFQ 생성 - 로그인한 사용자 ID 사용 const newRfq = await db.insert(procurementRfqs).values({ rfqCode, projectId: randomProject.id, series:seriesValue, itemCode: randomItem.itemCode || `ITEM-${Math.floor(Math.random() * 1000)}`, // itemId 대신 itemCode 사용 itemName: randomItem.itemName || `임의 아이템 ${Math.floor(Math.random() * 100)}`, // itemName 추가 dueDate, rfqSendDate: null, // null로 설정/ status: "RFQ Created", rfqSealedYn: false, picCode: `PIC-${Math.floor(Math.random() * 1000)}`, remark: "테스트용으로 아무말이나 들어간 것으로 실제로는 SAP에 있는 값이 옵니다. 오해 ㄴㄴ", createdBy: userId, updatedBy: userId, }).returning(); if (newRfq.length === 0) { return { success: false, message: 'RFQ 생성에 실패했습니다' }; } // PR 항목 생성 (1-3개 임의 생성) const prItemsCount = Math.floor(Math.random() * 3) + 1; const createdPrItems = []; for (let i = 0; i < prItemsCount; i++) { const deliveryDate = new Date(today); deliveryDate.setDate(today.getDate() + Math.floor(Math.random() * 60) + 30); // 30-90일 후 const randomTwoDigits = String(Math.floor(Math.random() * 100)).padStart(2, '0'); // 프로젝트와 아이템 코드가 있다고 가정하고, 없을 경우 기본값 사용 const projectCode = randomProject.code || 'PROJ'; const itemCode = randomItem.itemCode || 'ITEM'; const materialCode = `${projectCode}${itemCode}${randomTwoDigits}`; const isMajor = i === 0 ? true : Math.random() > 0.7; const newPrItem = await db.insert(prItems).values({ procurementRfqsId: newRfq[0].id, rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`, prItem: `PRI-${Math.floor(Math.random() * 1000)}`, prNo: `PRN-${Math.floor(Math.random() * 1000)}`, // itemId: randomItem.id, materialCode, materialCategory: "Standard", acc: `ACC-${Math.floor(Math.random() * 100)}`, materialDescription: `${['알루미늄', '구리', '철', '실리콘'][Math.floor(Math.random() * 4)]} 재질 부품`, size: `${Math.floor(Math.random() * 100) + 10}x${Math.floor(Math.random() * 100) + 10}`, deliveryDate, quantity: Math.floor(Math.random() * 100) + 1, uom: ['EA', 'KG', 'M', 'L'][Math.floor(Math.random() * 4)], grossWeight: Math.floor(Math.random() * 1000) / 10, gwUom: ['KG', 'T'][Math.floor(Math.random() * 2)], specNo: `SPEC-${Math.floor(Math.random() * 1000)}`, majorYn:isMajor, // 30% 확률로 true remark: "외부 시스템에서 가져온 PR 항목", }).returning(); createdPrItems.push(newPrItem[0]); } revalidateTag(`rfqs-po`) return { success: true, message: '외부 RFQ를 성공적으로 가져왔습니다', data: { rfq: newRfq[0], prItems: createdPrItems } }; } catch (error) { console.error('외부 RFQ 가져오기 오류:', error); return { success: false, message: '외부 RFQ를 가져오는 중 오류가 발생했습니다' }; } } /** * RFQ ID에 해당하는 모든 벤더 견적 정보를 조회하는 서버 액션 * @param rfqId RFQ ID * @returns 견적 정보 목록 */ export async function fetchVendorQuotations(rfqId: number) { try { // 벤더 정보와 함께 견적 정보 조회 const quotations = await db .select({ // 견적 기본 정보 id: procurementVendorQuotations.id, rfqId: procurementVendorQuotations.rfqId, vendorId: procurementVendorQuotations.vendorId, quotationCode: procurementVendorQuotations.quotationCode, quotationVersion: procurementVendorQuotations.quotationVersion, totalItemsCount: procurementVendorQuotations.totalItemsCount, subTotal: procurementVendorQuotations.subTotal, taxTotal: procurementVendorQuotations.taxTotal, discountTotal: procurementVendorQuotations.discountTotal, totalPrice: procurementVendorQuotations.totalPrice, currency: procurementVendorQuotations.currency, validUntil: procurementVendorQuotations.validUntil, estimatedDeliveryDate: procurementVendorQuotations.estimatedDeliveryDate, paymentTermsCode: procurementVendorQuotations.paymentTermsCode, incotermsCode: procurementVendorQuotations.incotermsCode, incotermsDetail: procurementVendorQuotations.incotermsDetail, status: procurementVendorQuotations.status, remark: procurementVendorQuotations.remark, rejectionReason: procurementVendorQuotations.rejectionReason, submittedAt: procurementVendorQuotations.submittedAt, acceptedAt: procurementVendorQuotations.acceptedAt, createdAt: procurementVendorQuotations.createdAt, updatedAt: procurementVendorQuotations.updatedAt, // 벤더 정보 vendorName: vendors.vendorName, paymentTermsDescription: paymentTerms.description, incotermsDescription: incoterms.description, }) .from(procurementVendorQuotations) .leftJoin(vendors, eq(procurementVendorQuotations.vendorId, vendors.id)) .leftJoin(paymentTerms, eq(procurementVendorQuotations.paymentTermsCode, paymentTerms.code)) .leftJoin(incoterms, eq(procurementVendorQuotations.incotermsCode, incoterms.code)) .where( and( eq(procurementVendorQuotations.rfqId, rfqId), // eq(procurementVendorQuotations.status, "Submitted") // <=== Submitted 상태만! ) ) .orderBy(desc(procurementVendorQuotations.updatedAt)) return { success: true, data: quotations } } catch (error) { console.error("벤더 견적 조회 오류:", error) return { success: false, error: "벤더 견적을 조회하는 중 오류가 발생했습니다" } } } /** * 견적 ID 목록에 해당하는 모든 견적 아이템 정보를 조회하는 서버 액션 * @param quotationIds 견적 ID 배열 * @returns 견적 아이템 정보 목록 */ export async function fetchQuotationItems(quotationIds: number[]) { try { // 빈 배열이 전달된 경우 빈 결과 반환 if (!quotationIds.length) { return { success: true, data: [] } } // 견적 아이템 정보 조회 const items = await db .select() .from(procurementQuotationItems) .where(inArray(procurementQuotationItems.quotationId, quotationIds)) .orderBy(procurementQuotationItems.id) return { success: true, data: items } } catch (error) { console.error("견적 아이템 조회 오류:", error) return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" } } }