diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 09:03:29 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 09:03:29 +0000 |
| commit | 95866a13ba4e1c235373834460aa284b763fe0d9 (patch) | |
| tree | 47a7a13d6e20907adbcbe04080f7c0aa3c7aea7f /lib/techsales-rfq/service.ts | |
| parent | 5c9b39eb011763a7491b3e8542de9f6d4976dd65 (diff) | |
(최겸) 기술영업 RFQ 개발(0620 요구사항, 첨부파일, REV 등)
Diffstat (limited to 'lib/techsales-rfq/service.ts')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 1043 |
1 files changed, 562 insertions, 481 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 96d6a3c9..25e1f379 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -5,7 +5,9 @@ import db from "@/db/db"; import { techSalesRfqs, techSalesVendorQuotations, + techSalesVendorQuotationRevisions, techSalesAttachments, + techSalesVendorQuotationAttachments, users, techSalesRfqComments, techSalesRfqItems, @@ -30,6 +32,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 @@ -79,16 +82,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise< return codes; } -/** - * 기술영업 조선 RFQ 생성 액션 - * - * 받을 파라미터 (생성시 입력하는 것) - * 1. RFQ 관련 - * 2. 프로젝트 관련 - * 3. 자재 관련 (자재그룹) - * - * 나머지 벤더, 첨부파일 등은 생성 이후 처리 - */ /** * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 @@ -309,8 +302,29 @@ export async function getTechSalesVendorQuotationsWithJoin(input: { limit: input.perPage, }); + // 각 견적서의 첨부파일 정보 조회 + const dataWithAttachments = await Promise.all( + data.map(async (quotation) => { + const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({ + where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id), + orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)], + }); + + return { + ...quotation, + quotationAttachments: attachments.map(att => ({ + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize, + filePath: att.filePath, + description: att.description, + })) + }; + }) + ); + const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere); - return { data, total }; + return { data: dataWithAttachments, total }; }); const pageCount = Math.ceil(total / input.perPage); @@ -414,160 +428,6 @@ export async function getTechSalesDashboardWithJoin(input: { } } - - -/** - * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함) - */ -export async function removeVendorFromTechSalesRfq(input: { - rfqId: number; - vendorId: number; -}) { - unstable_noStore(); - try { - // 먼저 해당 벤더의 견적서 상태 확인 - const existingQuotation = await db - .select() - .from(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - ) - .limit(1); - - if (existingQuotation.length === 0) { - return { - data: null, - error: "해당 벤더가 이 RFQ에 존재하지 않습니다." - }; - } - - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation[0].status !== "Draft") { - return { - data: null, - error: "Draft 상태의 벤더만 삭제할 수 있습니다." - }; - } - - // 해당 벤더의 견적서 삭제 - const deletedQuotations = await db - .delete(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - ) - .returning(); - - // RFQ 타입 조회 및 캐시 무효화 - const rfqForCache = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { rfqType: true } - }); - - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidateTag(`vendor-${input.vendorId}-quotations`); - revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP")); - - return { data: deletedQuotations[0], error: null }; - } catch (err) { - console.error("Error removing vendor from RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함) - */ -export async function removeVendorsFromTechSalesRfq(input: { - rfqId: number; - vendorIds: number[]; -}) { - unstable_noStore(); - try { - const results: typeof techSalesVendorQuotations.$inferSelect[] = []; - const errors: string[] = []; - - // 트랜잭션으로 처리 - await db.transaction(async (tx) => { - for (const vendorId of input.vendorIds) { - try { - // 먼저 해당 벤더의 견적서 상태 확인 - const existingQuotation = await tx - .select() - .from(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, vendorId) - ) - ) - .limit(1); - - if (existingQuotation.length === 0) { - errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`); - continue; - } - - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation[0].status !== "Draft") { - errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); - continue; - } - - // 해당 벤더의 견적서 삭제 - const deletedQuotations = await tx - .delete(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, vendorId) - ) - ) - .returning(); - - if (deletedQuotations.length > 0) { - results.push(deletedQuotations[0]); - } - } catch (vendorError) { - console.error(`Error removing vendor ${vendorId}:`, vendorError); - errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`); - } - } - }); - - // RFQ 타입 조회 및 캐시 무효화 - const rfqForCache2 = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { rfqType: true } - }); - - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP")); - - // 벤더별 캐시도 무효화 - for (const vendorId of input.vendorIds) { - revalidateTag(`vendor-${vendorId}-quotations`); - } - - return { - data: results, - error: errors.length > 0 ? errors.join(", ") : null, - successCount: results.length, - errorCount: errors.length - }; - } catch (err) { - console.error("Error removing vendors from RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - /** * 특정 RFQ의 벤더 목록 조회 */ @@ -716,6 +576,19 @@ export async function sendTechSalesRfqToVendors(input: { .set(updateData) .where(eq(techSalesRfqs.id, input.rfqId)); + // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경 + for (const quotation of vendorQuotations) { + if (quotation.status === "Assigned") { + await tx.update(techSalesVendorQuotations) + .set({ + status: "Draft", + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, quotation.id)); + } + } + // 2. 각 벤더에 대해 이메일 발송 처리 for (const quotation of vendorQuotations) { if (!quotation.vendorId || !quotation.vendor) continue; @@ -847,6 +720,12 @@ export async function getTechSalesVendorQuotation(quotationId: number) { const itemsResult = await getTechSalesRfqItems(quotation.rfqId); const items = itemsResult.data || []; + // 견적서 첨부파일 조회 + const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({ + where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId), + orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)], + }); + // 기존 구조와 호환되도록 데이터 재구성 const formattedQuotation = { id: quotation.id, @@ -911,7 +790,16 @@ export async function getTechSalesVendorQuotation(quotationId: number) { country: quotation.vendorCountry, email: quotation.vendorEmail, phone: quotation.vendorPhone, - } + }, + + // 첨부파일 정보 + quotationAttachments: quotationAttachments.map(attachment => ({ + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + filePath: attachment.filePath, + description: attachment.description, + })) }; return { data: formattedQuotation, error: null }; @@ -922,7 +810,8 @@ export async function getTechSalesVendorQuotation(quotationId: number) { } /** - * 기술영업 벤더 견적서 업데이트 (임시저장) + * 기술영업 벤더 견적서 업데이트 (임시저장), + * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함. */ export async function updateTechSalesVendorQuotation(data: { id: number @@ -931,46 +820,78 @@ export async function updateTechSalesVendorQuotation(data: { validUntil: Date remark?: string updatedBy: number + changeReason?: string }) { try { - // 현재 견적서 상태 및 벤더 ID 확인 - const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - columns: { - status: true, - vendorId: true, + return await db.transaction(async (tx) => { + // 현재 견적서 전체 데이터 조회 (revision 저장용) + const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, data.id), + }); + + if (!currentQuotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; } - }); - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } + // Accepted나 Rejected 상태가 아니면 수정 가능 + if (["Rejected"].includes(currentQuotation.status)) { + return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." }; + } - // Draft 또는 Revised 상태에서만 수정 가능 - if (!["Draft", "Revised"].includes(currentQuotation.status)) { - return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." }; - } + // 실제 변경사항이 있는지 확인 + const hasChanges = + currentQuotation.currency !== data.currency || + currentQuotation.totalPrice !== data.totalPrice || + currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() || + currentQuotation.remark !== (data.remark || null); - const result = await db - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning() + if (!hasChanges) { + return { data: currentQuotation, error: null }; + } - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + // 현재 버전을 revision history에 저장 + await tx.insert(techSalesVendorQuotationRevisions).values({ + quotationId: data.id, + version: currentQuotation.quotationVersion || 1, + snapshot: { + currency: currentQuotation.currency, + totalPrice: currentQuotation.totalPrice, + validUntil: currentQuotation.validUntil, + remark: currentQuotation.remark, + status: currentQuotation.status, + quotationVersion: currentQuotation.quotationVersion, + submittedAt: currentQuotation.submittedAt, + acceptedAt: currentQuotation.acceptedAt, + updatedAt: currentQuotation.updatedAt, + }, + changeReason: data.changeReason || "견적서 수정", + revisedBy: data.updatedBy, + }); - return { data: result[0], error: null } + // 새로운 버전으로 업데이트 + const result = await tx + .update(techSalesVendorQuotations) + .set({ + currency: data.currency, + totalPrice: data.totalPrice, + validUntil: data.validUntil, + remark: data.remark || null, + quotationVersion: (currentQuotation.quotationVersion || 1) + 1, + status: "Revised", // 수정된 상태로 변경 + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, data.id)) + .returning(); + + return { data: result[0], error: null }; + }); } catch (error) { - console.error("Error updating tech sales vendor quotation:", error) - return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" } + console.error("Error updating tech sales vendor quotation:", error); + return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }; + } finally { + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations"); + revalidatePath(`/partners/techsales/rfq-ship/${data.id}`); } } @@ -983,63 +904,134 @@ export async function submitTechSalesVendorQuotation(data: { totalPrice: string validUntil: Date remark?: string + attachments?: Array<{ + fileName: string + filePath: string + fileSize: number + }> updatedBy: number }) { try { - // 현재 견적서 상태 확인 - const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - columns: { - status: true, - vendorId: true, + return await db.transaction(async (tx) => { + // 현재 견적서 전체 데이터 조회 (revision 저장용) + const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, data.id), + }); + + if (!currentQuotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; } - }); - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } + // Rejected 상태에서는 제출 불가 + if (["Rejected"].includes(currentQuotation.status)) { + return { data: null, error: "거절된 견적서는 제출할 수 없습니다." }; + } + + // // 실제 변경사항이 있는지 확인 + // const hasChanges = + // currentQuotation.currency !== data.currency || + // currentQuotation.totalPrice !== data.totalPrice || + // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() || + // currentQuotation.remark !== (data.remark || null); + + // // 변경사항이 있거나 처음 제출하는 경우 revision 저장 + // if (hasChanges || currentQuotation.status === "Draft") { + // await tx.insert(techSalesVendorQuotationRevisions).values({ + // quotationId: data.id, + // version: currentQuotation.quotationVersion || 1, + // snapshot: { + // currency: currentQuotation.currency, + // totalPrice: currentQuotation.totalPrice, + // validUntil: currentQuotation.validUntil, + // remark: currentQuotation.remark, + // status: currentQuotation.status, + // quotationVersion: currentQuotation.quotationVersion, + // submittedAt: currentQuotation.submittedAt, + // acceptedAt: currentQuotation.acceptedAt, + // updatedAt: currentQuotation.updatedAt, + // }, + // changeReason: "견적서 제출", + // revisedBy: data.updatedBy, + // }); + // } + + // 항상 revision 저장 (변경사항 여부와 관계없이) + await tx.insert(techSalesVendorQuotationRevisions).values({ + quotationId: data.id, + version: currentQuotation.quotationVersion || 1, + snapshot: { + currency: currentQuotation.currency, + totalPrice: currentQuotation.totalPrice, + validUntil: currentQuotation.validUntil, + remark: currentQuotation.remark, + status: currentQuotation.status, + quotationVersion: currentQuotation.quotationVersion, + submittedAt: currentQuotation.submittedAt, + acceptedAt: currentQuotation.acceptedAt, + updatedAt: currentQuotation.updatedAt, + }, + changeReason: "견적서 제출", + revisedBy: data.updatedBy, + }); - // Draft 또는 Revised 상태에서만 제출 가능 - if (!["Draft", "Revised"].includes(currentQuotation.status)) { - return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." }; - } + // 새로운 버전 번호 계산 (항상 1 증가) + const newRevisionId = (currentQuotation.quotationVersion || 1) + 1; - const result = await db - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - status: "Submitted", - submittedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning() + // 새로운 버전으로 업데이트 + const result = await tx + .update(techSalesVendorQuotations) + .set({ + currency: data.currency, + totalPrice: data.totalPrice, + validUntil: data.validUntil, + remark: data.remark || null, + quotationVersion: newRevisionId, + status: "Submitted", + submittedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, data.id)) + .returning(); - // 메일 발송 (백그라운드에서 실행) - if (result[0]) { - // 벤더에게 견적 제출 확인 메일 발송 - sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { - console.error("벤더 견적 제출 확인 메일 발송 실패:", error); - }); + // 첨부파일 처리 (새로운 revisionId 사용) + if (data.attachments && data.attachments.length > 0) { + for (const attachment of data.attachments) { + await tx.insert(techSalesVendorQuotationAttachments).values({ + quotationId: data.id, + revisionId: newRevisionId, // 새로운 리비전 ID 사용 + fileName: attachment.fileName, + originalFileName: attachment.fileName, + fileSize: attachment.fileSize, + filePath: attachment.filePath, + fileType: attachment.fileName.split('.').pop() || 'unknown', + uploadedBy: data.updatedBy, + isVendorUpload: true, + }); + } + } - // 담당자에게 견적 접수 알림 메일 발송 - sendQuotationSubmittedNotificationToManager(data.id).catch(error => { - console.error("담당자 견적 접수 알림 메일 발송 실패:", error); - }); - } + // 메일 발송 (백그라운드에서 실행) + if (result[0]) { + // 벤더에게 견적 제출 확인 메일 발송 + sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { + console.error("벤더 견적 제출 확인 메일 발송 실패:", error); + }); - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`) - revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + // 담당자에게 견적 접수 알림 메일 발송 + sendQuotationSubmittedNotificationToManager(data.id).catch(error => { + console.error("담당자 견적 접수 알림 메일 발송 실패:", error); + }); + } - return { data: result[0], error: null } + return { data: result[0], error: null }; + }); } catch (error) { - console.error("Error submitting tech sales vendor quotation:", error) - return { data: null, error: "견적서 제출 중 오류가 발생했습니다" } + console.error("Error submitting tech sales vendor quotation:", error); + return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }; + } finally { + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations"); + revalidatePath(`/partners/techsales/rfq-ship`); } } @@ -1095,14 +1087,17 @@ export async function getVendorQuotations(input: { const offset = (page - 1) * perPage; const limit = perPage; - // 기본 조건: 해당 벤더의 견적서만 조회 + // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외) const vendorIdNum = parseInt(vendorId); if (isNaN(vendorIdNum)) { console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId); return { data: [], pageCount: 0, total: 0 }; } - const baseConditions = [eq(techSalesVendorQuotations.vendorId, vendorIdNum)]; + const baseConditions = [ + eq(techSalesVendorQuotations.vendorId, vendorIdNum), + sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외 + ]; // rfqType 필터링 추가 if (input.rfqType) { @@ -1210,9 +1205,13 @@ export async function getVendorQuotations(input: { description: techSalesRfqs.description, // 프로젝트 정보 (직접 조인) projNm: biddingProjects.projNm, - // 아이템 정보 추가 (임시로 description 사용) - // itemName: techSalesRfqs.description, - // 첨부파일 개수 + // 아이템 개수 + itemCount: sql<number>`( + SELECT COUNT(*) + FROM tech_sales_rfq_items + WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} + )`, + // RFQ 첨부파일 개수 attachmentCount: sql<number>`( SELECT COUNT(*) FROM tech_sales_attachments @@ -1221,6 +1220,7 @@ export async function getVendorQuotations(input: { }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(techSalesAttachments, eq(techSalesRfqs.id, techSalesAttachments.techSalesRfqId)) .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere) .orderBy(...orderBy) @@ -1256,48 +1256,6 @@ export async function getVendorQuotations(input: { } /** - * 벤더용 기술영업 견적서 상태별 개수 조회 - */ -export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") { - return unstable_cache( - async () => { - try { - const query = db - .select({ - status: techSalesVendorQuotations.status, - count: sql<number>`count(*)`, - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)); - - // 조건 설정 - const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; - if (rfqType) { - conditions.push(eq(techSalesRfqs.rfqType, rfqType)); - } - - const result = await query - .where(and(...conditions)) - .groupBy(techSalesVendorQuotations.status); - - return { data: result, error: null }; - } catch (err) { - console.error("Error fetching quotation status counts:", err); - return { data: null, error: getErrorMessage(err) }; - } - }, - [vendorId], // 캐싱 키 - { - revalidate: 60, // 1분간 캐시 - tags: [ - "techSalesVendorQuotations", - `vendor-${vendorId}-quotations` - ], - } - )(); -} - -/** * 기술영업 벤더 견적 승인 (벤더 선택) */ export async function acceptTechSalesVendorQuotation(quotationId: number) { @@ -1358,6 +1316,9 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { for (const vendorQuotation of allVendorsInRfq) { revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`); } + revalidatePath("/evcp/budgetary-tech-sales-ship") + revalidatePath("/partners/techsales") + return { success: true, data: result } } catch (error) { @@ -1370,7 +1331,7 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { } /** - * 기술영업 RFQ 첨부파일 생성 (파일 업로드) + * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x */ export async function createTechSalesRfqAttachments(params: { techSalesRfqId: number @@ -1415,8 +1376,7 @@ export async function createTechSalesRfqAttachments(params: { await fs.mkdir(rfqDir, { recursive: true }); for (const file of files) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); + const decryptedBuffer = await decryptWithServerAction(file); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; @@ -1424,7 +1384,7 @@ export async function createTechSalesRfqAttachments(params: { const absolutePath = path.join(process.cwd(), "public", relativePath); // 파일 저장 - await fs.writeFile(absolutePath, buffer); + await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -1488,6 +1448,39 @@ export async function getTechSalesRfqAttachments(techSalesRfqId: number) { } /** + * RFQ 첨부파일 타입별 조회 + */ +export async function getTechSalesRfqAttachmentsByType( + techSalesRfqId: number, + attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT" +) { + unstable_noStore(); + try { + const attachments = await db.query.techSalesAttachments.findMany({ + where: and( + eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), + eq(techSalesAttachments.attachmentType, attachmentType) + ), + orderBy: [desc(techSalesAttachments.createdAt)], + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }); + + return { data: attachments, error: null }; + } catch (err) { + console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** * 기술영업 RFQ 첨부파일 삭제 */ export async function deleteTechSalesRfqAttachment(attachmentId: number) { @@ -1561,7 +1554,7 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) { */ export async function processTechSalesRfqAttachments(params: { techSalesRfqId: number - newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[] + newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[] deleteAttachmentIds: number[] createdBy: number }) { @@ -1623,16 +1616,16 @@ export async function processTechSalesRfqAttachments(params: { await fs.mkdir(rfqDir, { recursive: true }); for (const { file, attachmentType, description } of newFiles) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); + // 파일 복호화 + const decryptedBuffer = await decryptWithServerAction(file); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); const absolutePath = path.join(process.cwd(), "public", relativePath); - // 파일 저장 - await fs.writeFile(absolutePath, buffer); + // 복호화된 파일 저장 + await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -2213,6 +2206,8 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb } } +// ==================== RFQ 조선/해양 관련 ==================== + /** * 기술영업 조선 RFQ 생성 (1:N 관계) */ @@ -2223,9 +2218,7 @@ export async function createTechSalesShipRfq(input: { description?: string; createdBy: number; }) { - unstable_noStore(); - console.log('🔍 createTechSalesShipRfq 호출됨:', input); - + unstable_noStore(); try { return await db.transaction(async (tx) => { // 프로젝트 정보 조회 (유효성 검증) @@ -2474,46 +2467,7 @@ export async function getTechSalesHullVendorQuotationsWithJoin(input: { return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" }); } -/** - * 조선 대시보드 전용 조회 함수 - */ -export async function getTechSalesShipDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" }); -} - -/** - * 해양 TOP 대시보드 전용 조회 함수 - */ -export async function getTechSalesTopDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" }); -} - -/** - * 해양 HULL 대시보드 전용 조회 함수 - */ -export async function getTechSalesHullDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" }); -} - -/** +/** * 기술영업 RFQ의 아이템 목록 조회 */ export async function getTechSalesRfqItems(rfqId: number) { @@ -2700,53 +2654,6 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) { } /** - * 기술영업 RFQ에 벤더 추가 (techVendors 기반) - */ -export async function addTechVendorToTechSalesRfq(input: { - rfqId: number; - vendorId: number; - createdBy: number; -}) { - unstable_noStore(); - - try { - return await db.transaction(async (tx) => { - // 벤더가 이미 추가되어 있는지 확인 - const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ - where: and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - }); - - if (existingQuotation) { - return { data: null, error: "이미 추가된 벤더입니다." }; - } - - // 새로운 견적서 레코드 생성 - const [quotation] = await tx - .insert(techSalesVendorQuotations) - .values({ - rfqId: input.rfqId, - vendorId: input.vendorId, - status: "Draft", - createdBy: input.createdBy, - updatedBy: input.createdBy, - }) - .returning({ id: techSalesVendorQuotations.id }); - - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - - return { data: quotation, error: null }; - }); - } catch (err) { - console.error("Error adding tech vendor to RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** * RFQ 타입에 따른 캐시 무효화 경로 반환 */ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string { @@ -2764,6 +2671,7 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string /** * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반) + * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성 */ export async function addTechVendorsToTechSalesRfq(input: { rfqId: number; @@ -2783,7 +2691,7 @@ export async function addTechVendorsToTechSalesRfq(input: { columns: { id: true, status: true, - rfqType: true + rfqType: true, } }); @@ -2791,10 +2699,10 @@ export async function addTechVendorsToTechSalesRfq(input: { throw new Error("RFQ를 찾을 수 없습니다"); } - // 2. 각 벤더에 대해 처리 + // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인) for (const vendorId of input.vendorIds) { try { - // 벤더가 이미 추가되어 있는지 확인 + // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인) const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ where: and( eq(techSalesVendorQuotations.rfqId, input.rfqId), @@ -2807,19 +2715,30 @@ export async function addTechVendorsToTechSalesRfq(input: { continue; } - // 새로운 견적서 레코드 생성 + // 벤더가 실제로 존재하는지 확인 + const vendor = await tx.query.techVendors.findFirst({ + where: eq(techVendors.id, vendorId), + columns: { id: true, vendorName: true } + }); + + if (!vendor) { + errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`); + continue; + } + + // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성 const [quotation] = await tx .insert(techSalesVendorQuotations) .values({ rfqId: input.rfqId, vendorId: vendorId, - status: "Draft", + status: "Assigned", // Draft가 아닌 Assigned 상태로 생성 createdBy: input.createdBy, updatedBy: input.createdBy, }) .returning({ id: techSalesVendorQuotations.id }); - - results.push(quotation); + + results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName }); } catch (vendorError) { console.error(`Error adding vendor ${vendorId}:`, vendorError); errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`); @@ -2843,11 +2762,6 @@ export async function addTechVendorsToTechSalesRfq(input: { revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP")); - // 벤더별 캐시도 무효화 - for (const vendorId of input.vendorIds) { - revalidateTag(`vendor-${vendorId}-quotations`); - } - return { data: results, error: errors.length > 0 ? errors.join(", ") : null, @@ -2921,9 +2835,9 @@ export async function removeTechVendorFromTechSalesRfq(input: { return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." }; } - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Draft") { - return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." }; + // Assigned 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Assigned") { + return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." }; } // 해당 벤더의 견적서 삭제 @@ -2977,9 +2891,9 @@ export async function removeTechVendorsFromTechSalesRfq(input: { continue; } - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Draft") { - errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); + // Assigned 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Assigned") { + errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`); continue; } @@ -3060,6 +2974,242 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType } } + +/** + * 벤더 견적서 거절 처리 (벤더가 직접 거절) + */ +export async function rejectTechSalesVendorQuotations(input: { + quotationIds: number[]; + rejectionReason?: string; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + + const result = await db.transaction(async (tx) => { + // 견적서들이 존재하고 벤더가 권한이 있는지 확인 + const quotations = await tx + .select({ + id: techSalesVendorQuotations.id, + status: techSalesVendorQuotations.status, + vendorId: techSalesVendorQuotations.vendorId, + }) + .from(techSalesVendorQuotations) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + if (quotations.length !== input.quotationIds.length) { + throw new Error("일부 견적서를 찾을 수 없습니다."); + } + + // 이미 거절된 견적서가 있는지 확인 + const alreadyRejected = quotations.filter(q => q.status === "Rejected"); + if (alreadyRejected.length > 0) { + throw new Error("이미 거절된 견적서가 포함되어 있습니다."); + } + + // 승인된 견적서가 있는지 확인 + const alreadyAccepted = quotations.filter(q => q.status === "Accepted"); + if (alreadyAccepted.length > 0) { + throw new Error("이미 승인된 견적서는 거절할 수 없습니다."); + } + + // 견적서 상태를 거절로 변경 + await tx + .update(techSalesVendorQuotations) + .set({ + status: "Rejected", + rejectionReason: input.rejectionReason || null, + updatedBy: parseInt(session.user.id), + updatedAt: new Date(), + }) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + return { success: true, updatedCount: quotations.length }; + }); + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + revalidatePath("/partners/techsales/rfq-ship", "page"); + return { + success: true, + message: `${result.updatedCount}개의 견적서가 거절되었습니다.`, + data: result + }; + } catch (error) { + console.error("견적서 거절 오류:", error); + return { + success: false, + error: getErrorMessage(error) + }; + } +} + +// ==================== Revision 관련 ==================== + +/** + * 견적서 revision 히스토리 조회 + */ +export async function getTechSalesVendorQuotationRevisions(quotationId: number) { + try { + const revisions = await db + .select({ + id: techSalesVendorQuotationRevisions.id, + version: techSalesVendorQuotationRevisions.version, + snapshot: techSalesVendorQuotationRevisions.snapshot, + changeReason: techSalesVendorQuotationRevisions.changeReason, + revisionNote: techSalesVendorQuotationRevisions.revisionNote, + revisedBy: techSalesVendorQuotationRevisions.revisedBy, + revisedAt: techSalesVendorQuotationRevisions.revisedAt, + // 수정자 정보 조인 + revisedByName: users.name, + }) + .from(techSalesVendorQuotationRevisions) + .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id)) + .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId)) + .orderBy(desc(techSalesVendorQuotationRevisions.version)); + + return { data: revisions, error: null }; + } catch (error) { + console.error("견적서 revision 히스토리 조회 오류:", error); + return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." }; + } +} + +/** + * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함) + */ +export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) { + try { + // 먼저 현재 견적서 조회 + const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우) + } + }); + + if (!currentQuotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; + } + + // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회 + const [revisionsResult, currentAttachments] = await Promise.all([ + getTechSalesVendorQuotationRevisions(quotationId), + getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0) + ]); + + // 현재 견적서에 첨부파일 정보 추가 + const currentWithAttachments = { + ...currentQuotation, + attachments: currentAttachments.data || [] + }; + + // 각 리비전의 첨부파일 정보 추가 + const revisionsWithAttachments = await Promise.all( + (revisionsResult.data || []).map(async (revision) => { + const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version); + return { + ...revision, + attachments: attachmentsResult.data || [] + }; + }) + ); + + return { + data: { + current: currentWithAttachments, + revisions: revisionsWithAttachments + }, + error: null + }; + } catch (error) { + console.error("견적서 전체 히스토리 조회 오류:", error); + return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." }; + } +} + +/** + * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬) + */ +export async function getTechSalesVendorQuotationAttachments(quotationId: number) { + return unstable_cache( + async () => { + try { + const attachments = await db + .select({ + id: techSalesVendorQuotationAttachments.id, + quotationId: techSalesVendorQuotationAttachments.quotationId, + revisionId: techSalesVendorQuotationAttachments.revisionId, + fileName: techSalesVendorQuotationAttachments.fileName, + originalFileName: techSalesVendorQuotationAttachments.originalFileName, + fileSize: techSalesVendorQuotationAttachments.fileSize, + fileType: techSalesVendorQuotationAttachments.fileType, + filePath: techSalesVendorQuotationAttachments.filePath, + description: techSalesVendorQuotationAttachments.description, + uploadedBy: techSalesVendorQuotationAttachments.uploadedBy, + vendorId: techSalesVendorQuotationAttachments.vendorId, + isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload, + createdAt: techSalesVendorQuotationAttachments.createdAt, + updatedAt: techSalesVendorQuotationAttachments.updatedAt, + }) + .from(techSalesVendorQuotationAttachments) + .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId)) + .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); + + return { data: attachments }; + } catch (error) { + console.error("견적서 첨부파일 조회 오류:", error); + return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." }; + } + }, + [`quotation-attachments-${quotationId}`], + { + revalidate: 60, + tags: [`quotation-${quotationId}`, "quotation-attachments"], + } + )(); +} + +/** + * 특정 리비전의 견적서 첨부파일 조회 + */ +export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) { + try { + const attachments = await db + .select({ + id: techSalesVendorQuotationAttachments.id, + quotationId: techSalesVendorQuotationAttachments.quotationId, + revisionId: techSalesVendorQuotationAttachments.revisionId, + fileName: techSalesVendorQuotationAttachments.fileName, + originalFileName: techSalesVendorQuotationAttachments.originalFileName, + fileSize: techSalesVendorQuotationAttachments.fileSize, + fileType: techSalesVendorQuotationAttachments.fileType, + filePath: techSalesVendorQuotationAttachments.filePath, + description: techSalesVendorQuotationAttachments.description, + uploadedBy: techSalesVendorQuotationAttachments.uploadedBy, + vendorId: techSalesVendorQuotationAttachments.vendorId, + isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload, + createdAt: techSalesVendorQuotationAttachments.createdAt, + updatedAt: techSalesVendorQuotationAttachments.updatedAt, + }) + .from(techSalesVendorQuotationAttachments) + .where(and( + eq(techSalesVendorQuotationAttachments.quotationId, quotationId), + eq(techSalesVendorQuotationAttachments.revisionId, revisionId) + )) + .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); + + return { data: attachments }; + } catch (error) { + console.error("리비전별 견적서 첨부파일 조회 오류:", error); + return { error: "첨부파일 조회 중 오류가 발생했습니다." }; + } +} + + +// ==================== Project AVL 관련 ==================== + /** * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함) */ @@ -3076,9 +3226,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: { try { const offset = (input.page - 1) * input.perPage; - // 기본 WHERE 조건: status = 'Accepted'만 조회 + // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 const baseConditions = [ - eq(techSalesVendorQuotations.status, 'Accepted') + eq(techSalesVendorQuotations.status, 'Accepted'), + sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외 ]; // 검색 조건 추가 @@ -3126,10 +3277,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: { // 필터 조건 추가 const filterConditions = []; if (input.filters?.length) { - const { filterWhere, joinOperator } = filterColumns({ + const filterWhere = filterColumns({ table: techSalesVendorQuotations, filters: input.filters, - joinOperator: input.joinOperator ?? "and", + joinOperator: "and", }); if (filterWhere) { filterConditions.push(filterWhere); @@ -3221,74 +3372,4 @@ export async function getAcceptedTechSalesVendorQuotations(input: { console.error("getAcceptedTechSalesVendorQuotations 오류:", error); throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`); } -} - -/** - * 벤더 견적서 거절 처리 (벤더가 직접 거절) - */ -export async function rejectTechSalesVendorQuotations(input: { - quotationIds: number[]; - rejectionReason?: string; -}) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - throw new Error("인증이 필요합니다."); - } - - const result = await db.transaction(async (tx) => { - // 견적서들이 존재하고 벤더가 권한이 있는지 확인 - const quotations = await tx - .select({ - id: techSalesVendorQuotations.id, - status: techSalesVendorQuotations.status, - vendorId: techSalesVendorQuotations.vendorId, - }) - .from(techSalesVendorQuotations) - .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); - - if (quotations.length !== input.quotationIds.length) { - throw new Error("일부 견적서를 찾을 수 없습니다."); - } - - // 이미 거절된 견적서가 있는지 확인 - const alreadyRejected = quotations.filter(q => q.status === "Rejected"); - if (alreadyRejected.length > 0) { - throw new Error("이미 거절된 견적서가 포함되어 있습니다."); - } - - // 승인된 견적서가 있는지 확인 - const alreadyAccepted = quotations.filter(q => q.status === "Accepted"); - if (alreadyAccepted.length > 0) { - throw new Error("이미 승인된 견적서는 거절할 수 없습니다."); - } - - // 견적서 상태를 거절로 변경 - await tx - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: input.rejectionReason || null, - updatedBy: parseInt(session.user.id), - updatedAt: new Date(), - }) - .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); - - return { success: true, updatedCount: quotations.length }; - }); - revalidateTag("techSalesRfqs"); - revalidateTag("techSalesVendorQuotations"); - revalidatePath("/partners/techsales/rfq-ship", "page"); - return { - success: true, - message: `${result.updatedCount}개의 견적서가 거절되었습니다.`, - data: result - }; - } catch (error) { - console.error("견적서 거절 오류:", error); - return { - success: false, - error: getErrorMessage(error) - }; - } }
\ No newline at end of file |
