summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor-response/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor-response/service.ts')
-rw-r--r--lib/rfq-last/vendor-response/service.ts483
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;
+}
+