diff options
Diffstat (limited to 'lib/rfq-last/vendor-response/service.ts')
| -rw-r--r-- | lib/rfq-last/vendor-response/service.ts | 483 |
1 files changed, 483 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts new file mode 100644 index 00000000..7de3ae58 --- /dev/null +++ b/lib/rfq-last/vendor-response/service.ts @@ -0,0 +1,483 @@ +// 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<VendorQuotationStatus, number>; + } + + // 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<VendorQuotationStatus, number>; + } + + // 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<VendorQuotationStatus, number> = { + "미응답": 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<VendorQuotationStatus, number>; + } + }, + [`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; +} + |
