// getVendorQuotationsLast.ts 'use server' import { revalidatePath, unstable_cache } from "next/cache"; import db from "@/db/db"; import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm"; import { rfqsLastView, rfqLastDetails, rfqLastVendorResponses, type RfqsLastView } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; import type { GetQuotationsLastSchema, UpdateParticipationSchema } from "@/lib/rfq-last/vendor-response/validations"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { getRfqAttachmentsAction } from "../service"; export type VendorQuotationStatus = | "미응답" // 초대받았지만 참여 여부 미결정 | "불참" // 참여 거절 | "작성중" // 참여 후 작성중 | "제출완료" // 견적서 제출 완료 | "수정요청" // 구매자가 수정 요청 | "최종확정" // 최종 확정됨 | "취소" // 취소됨 // 벤더 견적 뷰 타입 확장 export interface VendorQuotationView extends RfqsLastView { // 벤더 응답 정보 responseStatus?: VendorQuotationStatus; displayStatus?:string; responseVersion?: number; submittedAt?: Date; totalAmount?: number; vendorCurrency?: string; // 벤더별 조건 vendorPaymentTerms?: string; vendorIncoterms?: string; vendorDeliveryDate?: Date; participationStatus: "미응답" | "참여" | "불참" | null participationRepliedAt: Date | null nonParticipationReason: string | null } /** * 벤더별 RFQ 목록 조회 */ export async function getVendorQuotationsLast( input: GetQuotationsLastSchema, vendorId: string ) { return unstable_cache( async () => { try { const numericVendorId = parseInt(vendorId); if (isNaN(numericVendorId)) { return { data: [], pageCount: 0 }; } // 페이지네이션 설정 const page = input.page || 1; const perPage = input.perPage || 10; const offset = (page - 1) * perPage; // 1. 먼저 벤더가 포함된 RFQ ID들 조회 const vendorRfqIds = await db .select({ rfqsLastId: rfqLastDetails.rfqsLastId }) .from(rfqLastDetails) .where( and( eq(rfqLastDetails.vendorsId, numericVendorId), eq(rfqLastDetails.isLatest, true) ) ); const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null); if (rfqIds.length === 0) { return { data: [], pageCount: 0 }; } // 2. 필터링 설정 // advancedTable 모드로 where 절 구성 const advancedWhere = filterColumns({ table: rfqsLastView, filters: input.filters, joinOperator: input.joinOperator, }); // 글로벌 검색 조건 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(rfqsLastView.rfqCode, s), ilike(rfqsLastView.rfqTitle, s), ilike(rfqsLastView.itemName, s), ilike(rfqsLastView.projectName, s), ilike(rfqsLastView.packageName, s), ilike(rfqsLastView.status, s) ); } // RFQ ID 조건 (벤더가 포함된 RFQ만) const rfqIdWhere = inArray(rfqsLastView.id, rfqIds); // 모든 조건 결합 let whereConditions = [rfqIdWhere]; // 필수 조건 if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); // 최종 조건 const finalWhere = and(...whereConditions); // 3. 정렬 설정 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => { // @ts-ignore - 동적 속성 접근 return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]); }) : [desc(rfqsLastView.updatedAt)]; // 4. 메인 쿼리 실행 const quotations = await db .select() .from(rfqsLastView) .where(finalWhere) .orderBy(...orderBy) .limit(perPage) .offset(offset); // 5. 각 RFQ에 대한 벤더 응답 정보 조회 const quotationsWithResponse = await Promise.all( quotations.map(async (rfq) => { // 벤더 응답 정보 조회 const response = await db.query.rfqLastVendorResponses.findFirst({ where: and( eq(rfqLastVendorResponses.rfqsLastId, rfq.id), eq(rfqLastVendorResponses.vendorId, numericVendorId), eq(rfqLastVendorResponses.isLatest, true) ), columns: { status: true, responseVersion: true, submittedAt: true, totalAmount: true, vendorCurrency: true, vendorPaymentTermsCode: true, vendorIncotermsCode: true, vendorDeliveryDate: true, participationStatus: true, participationRepliedAt: true, nonParticipationReason: true, } }); // 벤더 상세 정보 조회 const detail = await db.query.rfqLastDetails.findFirst({ where: and( eq(rfqLastDetails.rfqsLastId, rfq.id), eq(rfqLastDetails.vendorsId, numericVendorId), eq(rfqLastDetails.isLatest, true) ), columns: { id: true, // rfqLastDetailsId 필요 emailSentAt: true, emailStatus: true, shortList: true, } }); // 표시할 상태 결정 (새로운 로직) let displayStatus: string | null = null; if (response) { // 응답 레코드가 있는 경우 if (response.participationStatus === "불참") { displayStatus = "불참"; } else if (response.participationStatus === "참여") { // 참여한 경우 실제 작업 상태 표시 displayStatus = response.status || "작성중"; } else { // participationStatus가 없거나 "미응답"인 경우 displayStatus = "미응답"; } } else { // 응답 레코드가 없는 경우 if (detail?.emailSentAt) { displayStatus = "미응답"; // 초대는 받았지만 응답 안함 } else { displayStatus = null; // 아직 초대도 안됨 } } return { ...rfq, // 새로운 상태 체계 displayStatus, // UI에서 표시할 통합 상태 // 참여 관련 정보 participationStatus: response?.participationStatus || "미응답", participationRepliedAt: response?.participationRepliedAt, nonParticipationReason: response?.nonParticipationReason, // 견적 작업 상태 (참여한 경우에만 의미 있음) responseStatus: response?.status, responseVersion: response?.responseVersion, submittedAt: response?.submittedAt, totalAmount: response?.totalAmount, vendorCurrency: response?.vendorCurrency, vendorPaymentTerms: response?.vendorPaymentTermsCode, vendorIncoterms: response?.vendorIncotermsCode, vendorDeliveryDate: response?.vendorDeliveryDate, // 초대 관련 정보 rfqLastDetailsId: detail?.id, // 참여 결정 시 필요 emailSentAt: detail?.emailSentAt, emailStatus: detail?.emailStatus, shortList: detail?.shortList, } as VendorQuotationView; }) ); // 6. 전체 개수 조회 const { totalCount } = await db .select({ totalCount: count() }) .from(rfqsLastView) .where(finalWhere) .then(rows => rows[0]); // 페이지 수 계산 const pageCount = Math.ceil(Number(totalCount) / perPage); return { data: quotationsWithResponse, pageCount }; } catch (err) { console.error("getVendorQuotationsLast 에러:", err); return { data: [], pageCount: 0 }; } }, [`vendor-quotations-last-${vendorId}-${JSON.stringify(input)}`], { revalidate: 60, tags: [`vendor-quotations-last-${vendorId}`], } )(); } export async function getQuotationStatusCountsLast(vendorId: string) { return unstable_cache( async () => { try { const numericVendorId = parseInt(vendorId); if (isNaN(numericVendorId)) { return { "미응답": 0, "불참": 0, "작성중": 0, "제출완료": 0, "수정요청": 0, "최종확정": 0, "취소": 0, } as Record; } // 1. 벤더가 초대받은 전체 RFQ 조회 const invitedRfqs = await db .select({ rfqsLastId: rfqLastDetails.rfqsLastId, }) .from(rfqLastDetails) .where( and( eq(rfqLastDetails.vendorsId, numericVendorId), eq(rfqLastDetails.isLatest, true) ) ); const invitedRfqIds = invitedRfqs.map(r => r.rfqsLastId); const totalInvited = invitedRfqIds.length; // 초대받은 RFQ가 없으면 모두 0 반환 if (totalInvited === 0) { return { "미응답": 0, "불참": 0, "작성중": 0, "제출완료": 0, "수정요청": 0, "최종확정": 0, "취소": 0, } as Record; } // 2. 벤더의 응답 상태 조회 const vendorResponses = await db .select({ participationStatus: rfqLastVendorResponses.participationStatus, status: rfqLastVendorResponses.status, rfqsLastId: rfqLastVendorResponses.rfqsLastId, }) .from(rfqLastVendorResponses) .where( and( eq(rfqLastVendorResponses.vendorId, numericVendorId), eq(rfqLastVendorResponses.isLatest, true), inArray(rfqLastVendorResponses.rfqsLastId, invitedRfqIds) ) ); // 3. 상태별 카운트 계산 const result: Record = { "미응답": 0, "불참": 0, "작성중": 0, "제출완료": 0, "수정요청": 0, "최종확정": 0, "취소": 0, }; // 응답이 있는 RFQ ID 세트 const respondedRfqIds = new Set(vendorResponses.map(r => r.rfqsLastId)); // 미응답 = 초대받았지만 응답 레코드가 없거나 participationStatus가 미응답인 경우 result["미응답"] = totalInvited - respondedRfqIds.size; // 응답별 상태 카운트 vendorResponses.forEach(response => { // 불참한 경우 if (response.participationStatus === "불참") { result["불참"]++; } // 참여했지만 아직 participationStatus가 없는 경우 (기존 데이터 호환성) else if (!response.participationStatus || response.participationStatus === "미응답") { // 응답 레코드는 있지만 참여 여부 미결정 result["미응답"]++; } // 참여한 경우 - status에 따라 분류 else if (response.participationStatus === "참여") { switch (response.status) { case "대기중": case "작성중": result["작성중"]++; break; case "제출완료": result["제출완료"]++; break; case "수정요청": result["수정요청"]++; break; case "최종확정": result["최종확정"]++; break; case "취소": result["취소"]++; break; default: // 기존 상태 호환성 처리 if (response.status === "초대됨") { result["미응답"]++; } else if (response.status === "제출완료" || response.status === "Submitted") { result["제출완료"]++; } break; } } }); return result; } catch (err) { console.error("getQuotationStatusCountsLast 에러:", err); return { "미응답": 0, "불참": 0, "작성중": 0, "제출완료": 0, "수정요청": 0, "최종확정": 0, "취소": 0, } as Record; } }, [`quotation-status-counts-last-${vendorId}`], { revalidate: 60, tags: [`quotation-status-last-${vendorId}`], } )(); } export async function updateParticipationStatus( input: UpdateParticipationSchema ) { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } const vendorId = session.user.companyId; const { rfqId, rfqLastDetailsId, participationStatus, nonParticipationReason } = input // 기존 응답 레코드 찾기 또는 생성 const existingResponse = await db .select() .from(rfqLastVendorResponses) .where( and( eq(rfqLastVendorResponses.rfqsLastId, rfqId), eq(rfqLastVendorResponses.vendorId, Number(vendorId)), eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetailsId), eq(rfqLastVendorResponses.isLatest, true) ) ) .limit(1) const now = new Date() const userId = parseInt(session.user.id) if (existingResponse.length > 0) { // 기존 레코드 업데이트 await db .update(rfqLastVendorResponses) .set({ participationStatus, participationRepliedAt: now, participationRepliedBy: userId, nonParticipationReason: participationStatus === "불참" ? nonParticipationReason : null, status: participationStatus === "참여" ? "작성중" : "대기중", updatedAt: now, updatedBy:userId, }) .where(eq(rfqLastVendorResponses.id, existingResponse[0].id)) } // revalidatePath("/vendor/quotations") return { success: true, message: participationStatus === "참여" ? "견적 참여가 확정되었습니다." : "견적 불참이 처리되었습니다." } } catch (error) { console.error("참여 여부 업데이트 에러:", error) return { success: false, message: "참여 여부 업데이트 중 오류가 발생했습니다." } } } interface UpdateVendorContractRequirementsParams { rfqId: number; detailId: number; contractRequirements: { agreementYn: boolean; ndaYn: boolean; gtcType: "general" | "project" | "none"; }; } interface UpdateResult { success: boolean; error?: string; data?: any; }