diff options
Diffstat (limited to 'lib/rfq-last/service.ts')
| -rw-r--r-- | lib/rfq-last/service.ts | 1267 |
1 files changed, 1207 insertions, 60 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ffeed1b1..67cb901f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -1,12 +1,14 @@ // lib/rfq/service.ts 'use server' -import { unstable_cache, unstable_noStore } from "next/cache"; +import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema"; -import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema"; +import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -172,68 +174,57 @@ export const findRfqLastById = async (id: number): Promise<RfqsLastView | null> }; -export async function getRfqLastAttachments( - input: GetRfqLastAttachmentsSchema, - rfqId: number, - attachmentType: "설계" | "구매" -) { +// 모든 첨부파일을 가져오는 새로운 서버 액션 +export async function getRfqAllAttachments(rfqId: number) { try { - const offset = (input.page - 1) * input.perPage - - // Advanced Filter 처리 (메인 테이블 기준) - const advancedWhere = filterColumns({ - table: rfqLastAttachments, - filters: input.filters, - joinOperator: input.joinOperator, - }) - - // 전역 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - ilike(rfqLastAttachments.serialNo, s), - ilike(rfqLastAttachments.description, s), - ilike(rfqLastAttachments.currentRevision, s), - ilike(rfqLastAttachmentRevisions.fileName, s), - ilike(rfqLastAttachmentRevisions.originalFileName, s) + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + rfqId: rfqLastAttachments.rfqId, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + description: rfqLastAttachments.description, + createdBy: rfqLastAttachments.createdBy, + createdAt: rfqLastAttachments.createdAt, + updatedAt: rfqLastAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) ) - } + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(eq(rfqLastAttachments.rfqId, rfqId)) + .orderBy(desc(rfqLastAttachments.createdAt)) - // 파일 타입 필터 - let fileTypeWhere - if (input.fileType && input.fileType.length > 0) { - fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType) + return { + data, + success: true } - - // 최종 WHERE 절 - const finalWhere = and( - eq(rfqLastAttachments.rfqId, rfqId), - eq(rfqLastAttachments.attachmentType, attachmentType), - advancedWhere, - globalWhere, - fileTypeWhere - ) - - // 정렬 - const orderBy = input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - ) - : [desc(rfqLastAttachments.createdAt)] - - // 데이터 조회 (기존 코드와 동일) - const { data, total } = await db.transaction(async (tx) => { - // ... 기존 조회 로직 - }) - - const pageCount = Math.ceil(total / input.perPage) - return { data, pageCount } } catch (err) { - console.error("getRfqAttachments error:", err) - return { data: [], pageCount: 0 } + console.error("getRfqAllAttachments error:", err) + return { + data: [], + success: false + } } } // 사용자 목록 조회 (필터용) @@ -689,3 +680,1159 @@ export async function getRfqBasicInfoAction(rfqId: number) { } } +export interface RevisionHistory { + id: number; + attachmentId: number; + revisionNo: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + isLatest: boolean; + revisionComment: string | null; + createdBy: number; + createdAt: Date; + createdByName: string | null; +} + +export interface AttachmentWithHistory { + id: number; + serialNo: string | null; + description: string | null; + currentRevision: string | null; + originalFileName: string | null; + revisions: RevisionHistory[]; +} + +// 리비전 히스토리 조회 +export async function getRevisionHistory(attachmentId: number): Promise<{ + success: boolean; + data?: AttachmentWithHistory; + error?: string; +}> { + try { + // 첨부파일 기본 정보 조회 + const [attachment] = await db + .select({ + id: rfqLastAttachments.id, + serialNo: rfqLastAttachments.serialNo, + description: rfqLastAttachments.description, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + }) + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!attachment) { + return { + success: false, + error: "첨부파일을 찾을 수 없습니다.", + }; + } + + // 최신 리비전 정보 조회 (파일명 가져오기 위해) + let originalFileName: string | null = null; + if (attachment.latestRevisionId) { + const [latestRevision] = await db + .select({ + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); + + originalFileName = latestRevision?.originalFileName || null; + } + + // 모든 리비전 히스토리 조회 + const revisions = await db + .select({ + id: rfqLastAttachmentRevisions.id, + attachmentId: rfqLastAttachmentRevisions.attachmentId, + revisionNo: rfqLastAttachmentRevisions.revisionNo, + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + isLatest: rfqLastAttachmentRevisions.isLatest, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + createdBy: rfqLastAttachmentRevisions.createdBy, + createdAt: rfqLastAttachmentRevisions.createdAt, + createdByName: users.name, + }) + .from(rfqLastAttachmentRevisions) + .leftJoin(users, eq(rfqLastAttachmentRevisions.createdBy, users.id)) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)) + .orderBy(desc(rfqLastAttachmentRevisions.createdAt)); + + return { + success: true, + data: { + ...attachment, + originalFileName, + revisions, + }, + }; + } catch (error) { + console.error("Get revision history error:", error); + return { + success: false, + error: "리비전 히스토리 조회 중 오류가 발생했습니다.", + }; + } +} + +// 특정 리비전 다운로드 URL 생성 +export async function getRevisionDownloadUrl(revisionId: number): Promise<{ + success: boolean; + data?: { + url: string; + fileName: string; + }; + error?: string; +}> { + try { + const [revision] = await db + .select({ + filePath: rfqLastAttachmentRevisions.filePath, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, revisionId)); + + if (!revision) { + return { + success: false, + error: "리비전을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: { + url: revision.filePath, + fileName: revision.originalFileName, + }, + }; + } catch (error) { + console.error("Get revision download URL error:", error); + return { + success: false, + error: "다운로드 URL 생성 중 오류가 발생했습니다.", + }; + } +} + +export async function getRfqVendorAttachments(rfqId: number) { + try { + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + + // 업로더 정보 + uploadedByName: users.name, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + + // 응답 상태 + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqLastVendorAttachments) + .leftJoin( + rfqLastVendorResponses, + eq(rfqLastVendorAttachments.vendorResponseId, rfqLastVendorResponses.id) + ) + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) + + return { + vendorData, + vendorSuccess: true + } + } catch (err) { + console.error("getRfqVendorAttachments error:", err) + return { + vendorData: [], + vendorSuccess: false + } + } +} + + + +// 벤더 추가 액션 +export async function addVendorToRfq({ + rfqId, + vendorId, + conditions, +}: { + rfqId: number; + vendorId: number; + conditions: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + // 중복 체크 + const existing = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 추가된 벤더입니다." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...conditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: conditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: conditions.currency, + vendorPaymentTermsCode: conditions.paymentTermsCode, + vendorIncotermsCode: conditions.incotermsCode, + vendorIncotermsDetail: conditions.incotermsDetail, + vendorDeliveryDate: conditions.deliveryDate, + vendorContractDuration: conditions.contractDuration, + vendorTaxCode: conditions.taxCode, + vendorPlaceOfShipping: conditions.placeOfShipping, + vendorPlaceOfDestination: conditions.placeOfDestination, + vendorMaterialPriceRelatedYn: conditions.materialPriceRelatedYn, + vendorSparepartYn: conditions.sparepartYn, + vendorFirstYn: conditions.firstYn, + vendorFirstDescription: conditions.firstDescription, + vendorSparepartDescription: conditions.sparepartDescription, + createdBy: user.id, + updatedBy: user.id, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { action: "벤더 초대", conditions }, + performedBy: userId, + }); + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Add vendor error:", error); + return { success: false, error: "벤더 추가 중 오류가 발생했습니다." }; + } +} + +export async function addVendorsToRfq({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions?: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + } | null; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + // 빈 배열 체크 + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 중복 체크 - 이미 추가된 벤더들 확인 + const existingVendors = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + const existingVendorIds = existingVendors.map(v => v.vendorId); + const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); + + if (newVendorIds.length === 0) { + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." + }; + } + + // 일부만 중복인 경우 경고 메시지 준비 + const skippedCount = vendorIds.length - newVendorIds.length; + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const addedVendors = []; + + for (const vendorId of newVendorIds) { + // conditions가 없는 경우 기본값 설정 + const vendorConditions = conditions || { + currency: "USD", + paymentTermsCode: "NET30", + incotermsCode: "FOB", + deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 + taxCode: "VV", + }; + + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...vendorConditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: vendorConditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: vendorConditions.currency, + vendorPaymentTermsCode: vendorConditions.paymentTermsCode, + vendorIncotermsCode: vendorConditions.incotermsCode, + vendorIncotermsDetail: vendorConditions.incotermsDetail, + vendorDeliveryDate: vendorConditions.deliveryDate, + vendorContractDuration: vendorConditions.contractDuration, + vendorTaxCode: vendorConditions.taxCode, + vendorPlaceOfShipping: vendorConditions.placeOfShipping, + vendorPlaceOfDestination: vendorConditions.placeOfDestination, + vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, + vendorSparepartYn: vendorConditions.sparepartYn, + vendorFirstYn: vendorConditions.firstYn, + vendorFirstDescription: vendorConditions.firstDescription, + vendorSparepartDescription: vendorConditions.sparepartDescription, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { + action: "벤더 초대", + conditions: vendorConditions, + batchAdd: true, + totalVendors: newVendorIds.length + }, + performedBy: userId, + }); + + addedVendors.push({ + vendorId, + detailId: detail.id, + responseId: response.id, + }); + } + + return addedVendors; + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + // 성공 메시지 구성 + let message = `${results.length}개 벤더가 추가되었습니다.`; + if (skippedCount > 0) { + message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; + } + + return { + success: true, + data: { + added: results.length, + skipped: skippedCount, + message, + } + }; + } catch (error) { + console.error("Add vendors error:", error); + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." + }; + } +} + +// 벤더 조건 일괄 업데이트 함수 (추가) +export async function updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions: { + currency?: string; + paymentTermsCode?: string; + incotermsCode?: string; + incotermsDetail?: string; + deliveryDate?: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails 업데이트 + await tx + .update(rfqLastDetails) + .set({ + ...conditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 + const vendorConditions = Object.keys(conditions).reduce((acc, key) => { + if (conditions[key] !== undefined) { + acc[`vendor${key.charAt(0).toUpperCase() + key.slice(1)}`] = conditions[key]; + } + return acc; + }, {}); + + await tx + .update(rfqLastVendorResponses) + .set({ + ...vendorConditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + // 3. 이력 기록 (각 벤더별로) + const responses = await tx + .select({ id: rfqLastVendorResponses.id }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + for (const response of responses) { + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "조건변경", + changeDetails: { + action: "조건 일괄 업데이트", + conditions, + batchUpdate: true, + totalVendors: vendorIds.length + }, + performedBy: userId, + }); + } + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { + success: true, + data: { + message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` + } + }; + } catch (error) { + console.error("Update vendor conditions error:", error); + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ 발송 액션 +export async function sendRfqToVendors({ + rfqId, + vendorIds, +}: { + rfqId: number; + vendorIds: number[]; +}) { + try { + + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + const userId = Number(session.user.id) + + // 벤더별 응답 상태 업데이트 + for (const vendorId of vendorIds) { + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response) { + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status: "작성중", + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, response.id)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "발송", + previousStatus: response.status, + newStatus: "작성중", + changeDetails: { action: "RFQ 발송" }, + performedBy: userId, + }); + } + } + + // TODO: 실제 이메일 발송 로직 + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true, count: vendorIds.length }; + } catch (error) { + console.error("Send RFQ error:", error); + return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; + } +} + +// 벤더 삭제 액션 +export async function removeVendorFromRfq({ + rfqId, + vendorId, +}: { + rfqId: number; + vendorId: number; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + // 응답 체크 + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response && response.status !== "초대됨") { + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + }; + } + + // 삭제 + await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Remove vendor error:", error); + return { success: false, error: "벤더 삭제 중 오류가 발생했습니다." }; + } +} + +// 벤더 응답 상태 업데이트 +export async function updateVendorResponseStatus({ + responseId, + status, + reason, +}: { + responseId: number; + status: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + reason?: string; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + + const [current] = await db + .select() + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.id, responseId)) + .limit(1); + + if (!current) { + return { success: false, error: "응답을 찾을 수 없습니다." }; + } + + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status, + submittedAt: status === "제출완료" ? new Date() : current.submittedAt, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, responseId)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: getActionFromStatus(status), + previousStatus: current.status, + newStatus: status, + changeReason: reason, + performedBy: Number(session.user.id), + }); + + revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Update status error:", error); + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }; + } +} + +// 상태에 따른 액션 텍스트 +function getActionFromStatus(status: string): string { + switch (status) { + case "제출완료": return "제출"; + case "수정요청": return "반려"; + case "최종확정": return "승인"; + case "취소": return "취소"; + default: return "수정"; + } +} + +export async function getRfqVendorResponses(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 + const rfqData = await db + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + title: rfqsLast.title, + status: rfqsLast.status, + startDate: rfqsLast.startDate, + endDate: rfqsLast.endDate, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + error: "RFQ를 찾을 수 없습니다.", + data: null + }; + } + + // 2. RFQ 세부 정보 조회 (복수 버전이 있을 수 있음) + const details = await db + .select() + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastDetails.version)); + + // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) + const vendorResponsesData = await db + .select({ + // 응답 기본 정보 + id: rfqLastVendorResponses.id, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + rfqLastDetailsId: rfqLastVendorResponses.rfqLastDetailsId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, + status: rfqLastVendorResponses.status, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + + // 제출 정보 + submittedAt: rfqLastVendorResponses.submittedAt, + submittedBy: rfqLastVendorResponses.submittedBy, + submittedByName: users.name, + + // 금액 정보 + totalAmount: rfqLastVendorResponses.totalAmount, + currency: rfqLastVendorResponses.currency, + + // 벤더 제안 조건 + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + + // 초도품/Spare part 응답 + vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, + vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, + + // 비고 + generalRemark: rfqLastVendorResponses.generalRemark, + technicalProposal: rfqLastVendorResponses.technicalProposal, + + // 타임스탬프 + createdAt: rfqLastVendorResponses.createdAt, + updatedAt: rfqLastVendorResponses.updatedAt, + }) + .from(rfqLastVendorResponses) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .leftJoin(users, eq(rfqLastVendorResponses.submittedBy, users.id)) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.isLatest, true) // 최신 버전만 조회 + ) + ) + .orderBy(desc(rfqLastVendorResponses.createdAt)); + + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 + const vendorResponsesWithCounts = await Promise.all( + vendorResponsesData.map(async (response) => { + // 견적 아이템 수 조회 + const itemCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorQuotationItems) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); + + // 첨부파일 수 조회 + const attachmentCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + + return { + ...response, + quotedItemCount: itemCount[0]?.count || 0, + attachmentCount: attachmentCount[0]?.count || 0, + }; + }) + ); + + // 5. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithCounts.map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status || "초대됨", // 기본값 설정 + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + }, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + }, + + // 초도품/Spare part + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + acceptance: response.vendorSparepartAcceptance, + }, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotedItemCount, + attachments: response.attachmentCount, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); + + return { + success: true, + data: formattedResponses, + rfq: rfqData[0], + details: details, + }; + + } catch (error) { + console.error("Failed to get vendor responses:", error); + return { + success: false, + error: error instanceof Error ? error.message : "벤더 응답 정보를 가져오는데 실패했습니다.", + data: null, + }; + } +} + +export async function getRfqWithDetails(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 (rfqsLastView 활용) + const [rfqData] = await db + .select() + .from(rfqsLastView) + .where(eq(rfqsLastView.id, rfqId)); + + if (!rfqData) { + return { success: false, error: "RFQ를 찾을 수 없습니다." }; + } + + // 2. 벤더별 상세 조건 조회 (rfqLastDetailsView 활용) + const details = await db + .select() + .from(rfqLastDetailsView) + .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .orderBy(desc(rfqLastDetailsView.detailId)); + + return { + success: true, + data: { + // RFQ 기본 정보 (rfqsLastView에서 제공) + id: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqType: rfqData.rfqType, + rfqTitle: rfqData.rfqTitle, + series: rfqData.series, + rfqSealedYn: rfqData.rfqSealedYn, + + // ITB 관련 + projectCompany: rfqData.projectCompany, + projectFlag: rfqData.projectFlag, + projectSite: rfqData.projectSite, + smCode: rfqData.smCode, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: rfqData.projectCode, + projectName: rfqData.projectName, + + // 아이템 정보 + itemCode: rfqData.itemCode, + itemName: rfqData.itemName, + + // 패키지 정보 + packageNo: rfqData.packageNo, + packageName: rfqData.packageName, + + // 날짜 및 상태 + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + + // PIC 정보 + picId: rfqData.picId, + picCode: rfqData.picCode, + picName: rfqData.picName, + picUserName: rfqData.picUserName, + engPicName: rfqData.engPicName, + + // 집계 정보 (View에서 이미 계산됨) + vendorCount: rfqData.vendorCount, + shortListedVendorCount: rfqData.shortListedVendorCount, + quotationReceivedCount: rfqData.quotationReceivedCount, + prItemsCount: rfqData.prItemsCount, + majorItemsCount: rfqData.majorItemsCount, + + // 견적 제출 정보 + earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, + + // Major Item 정보 + majorItemMaterialCode: rfqData.majorItemMaterialCode, + majorItemMaterialDescription: rfqData.majorItemMaterialDescription, + majorItemMaterialCategory: rfqData.majorItemMaterialCategory, + majorItemPrNo: rfqData.majorItemPrNo, + + // 감사 정보 + createdBy: rfqData.createdBy, + createdByUserName: rfqData.createdByUserName, + createdAt: rfqData.createdAt, + sentBy: rfqData.sentBy, + sentByUserName: rfqData.sentByUserName, + updatedBy: rfqData.updatedBy, + updatedByUserName: rfqData.updatedByUserName, + updatedAt: rfqData.updatedAt, + + // 비고 + remark: rfqData.remark, + + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) + details: details.map(d => ({ + detailId: d.detailId, + + // 벤더 정보 + vendorId: d.vendorId, + vendorName: d.vendorName, + vendorCode: d.vendorCode, + vendorCountry: d.vendorCountry, + + // 조건 정보 + currency: d.currency, + paymentTermsCode: d.paymentTermsCode, + paymentTermsDescription: d.paymentTermsDescription, + incotermsCode: d.incotermsCode, + incotermsDescription: d.incotermsDescription, + incotermsDetail: d.incotermsDetail, + deliveryDate: d.deliveryDate, + contractDuration: d.contractDuration, + taxCode: d.taxCode, + placeOfShipping: d.placeOfShipping, + placeOfDestination: d.placeOfDestination, + + // Boolean 필드들 + shortList: d.shortList, + returnYn: d.returnYn, + returnedAt: d.returnedAt, + prjectGtcYn: d.prjectGtcYn, + generalGtcYn: d.generalGtcYn, + ndaYn: d.ndaYn, + agreementYn: d.agreementYn, + materialPriceRelatedYn: d.materialPriceRelatedYn, + sparepartYn: d.sparepartYn, + firstYn: d.firstYn, + + // 설명 필드 + firstDescription: d.firstDescription, + sparepartDescription: d.sparepartDescription, + remark: d.remark, + cancelReason: d.cancelReason, + + // 견적 관련 정보 (View에서 이미 계산됨) + hasQuotation: d.hasQuotation, + quotationStatus: d.quotationStatus, + quotationTotalPrice: d.quotationTotalPrice, + quotationVersion: d.quotationVersion, + quotationVersionCount: d.quotationVersionCount, + lastQuotationDate: d.lastQuotationDate, + quotationSubmittedAt: d.quotationSubmittedAt, + + // 감사 정보 + updatedBy: d.updatedBy, + updatedByUserName: d.updatedByUserName, + updatedAt: d.updatedAt, + })), + } + }; + } catch (error) { + console.error("Get RFQ with details error:", error); + return { success: false, error: "데이터 조회 중 오류가 발생했습니다." }; + } +}
\ No newline at end of file |
