diff options
| author | joonhoekim <26rote@gmail.com> | 2025-05-29 05:12:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-29 05:37:04 +0000 |
| commit | e484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch) | |
| tree | d18133dde99e6feb773c95d04f7e79715ab24252 /lib/techsales-rfq/service.ts | |
| parent | 37f55540833c2d5894513eca9fc8f7c6233fc2d2 (diff) | |
(김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업)
Diffstat (limited to 'lib/techsales-rfq/service.ts')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 1008 |
1 files changed, 1005 insertions, 3 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 88fef4b7..7be91092 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -4,10 +4,11 @@ import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; import db from "@/db/db"; import { techSalesRfqs, - techSalesVendorQuotations, + techSalesVendorQuotations, + techSalesAttachments, items, users, - TECH_SALES_QUOTATION_STATUSES + techSalesRfqComments } from "@/db/schema"; import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; @@ -166,7 +167,7 @@ export async function createTechSalesRfq(input: { // 각 자재그룹 코드별로 RFQ 생성 for (const materialCode of input.materialGroupCodes) { - // RFQ 코드 생성 (임시로 타임스탬프 기반) + // RFQ 코드 생성 const rfqCode = await generateRfqCodes(tx, 1); // 기본 due date 설정 (7일 후) @@ -1238,6 +1239,19 @@ export async function submitTechSalesVendorQuotation(data: { .where(eq(techSalesVendorQuotations.id, data.id)) .returning() + // 메일 발송 (백그라운드에서 실행) + if (result[0]) { + // 벤더에게 견적 제출 확인 메일 발송 + sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { + console.error("벤더 견적 제출 확인 메일 발송 실패:", error); + }); + + // 담당자에게 견적 접수 알림 메일 발송 + sendQuotationSubmittedNotificationToManager(data.id).catch(error => { + console.error("담당자 견적 접수 알림 메일 발송 실패:", error); + }); + } + // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) @@ -1385,6 +1399,12 @@ export async function getVendorQuotations(input: { itemName: items.itemName, // 프로젝트 정보 (JSON에서 추출) projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, + // 첨부파일 개수 + attachmentCount: sql<number>`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) @@ -1491,6 +1511,34 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { return quotation }) + // 메일 발송 (백그라운드에서 실행) + // 선택된 벤더에게 견적 선택 알림 메일 발송 + sendQuotationAcceptedNotification(quotationId).catch(error => { + console.error("벤더 견적 선택 알림 메일 발송 실패:", error); + }); + + // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 + setTimeout(async () => { + try { + const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ + where: and( + eq(techSalesVendorQuotations.rfqId, result.rfqId), + ne(techSalesVendorQuotations.id, quotationId), + eq(techSalesVendorQuotations.status, "Rejected") + ), + columns: { id: true } + }); + + for (const rejectedQuotation of rejectedQuotations) { + sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { + console.error("벤더 견적 거절 알림 메일 발송 실패:", error); + }); + } + } catch (error) { + console.error("거절된 견적 알림 메일 발송 중 오류:", error); + } + }, 1000); // 1초 후 실행 + // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidateTag(`techSalesRfq-${result.rfqId}`) @@ -1525,6 +1573,11 @@ export async function rejectTechSalesVendorQuotation(quotationId: number, reject throw new Error("견적을 찾을 수 없습니다") } + // 메일 발송 (백그라운드에서 실행) + sendQuotationRejectedNotification(quotationId).catch(error => { + console.error("벤더 견적 거절 알림 메일 발송 실패:", error); + }); + // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidateTag(`techSalesRfq-${result[0].rfqId}`) @@ -1537,4 +1590,953 @@ export async function rejectTechSalesVendorQuotation(quotationId: number, reject error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" } } +} + +/** + * 기술영업 RFQ 첨부파일 생성 (파일 업로드) + */ +export async function createTechSalesRfqAttachments(params: { + techSalesRfqId: number + files: File[] + createdBy: number + attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC" + description?: string +}) { + unstable_noStore(); + try { + const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params; + + if (!files || files.length === 0) { + return { data: null, error: "업로드할 파일이 없습니다." }; + } + + // RFQ 존재 확인 + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, techSalesRfqId), + columns: { id: true, status: true } + }); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + // 편집 가능한 상태 확인 + if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { + return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." }; + } + + const results: typeof techSalesAttachments.$inferSelect[] = []; + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + const path = await import("path"); + const fs = await import("fs/promises"); + const { randomUUID } = await import("crypto"); + + // 파일 저장 디렉토리 생성 + const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); + await fs.mkdir(rfqDir, { recursive: true }); + + for (const file of files) { + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 고유 파일명 생성 + 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); + + // DB에 첨부파일 레코드 생성 + const [newAttachment] = await tx.insert(techSalesAttachments).values({ + techSalesRfqId, + attachmentType, + fileName: uniqueName, + originalFileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + fileSize: file.size, + fileType: file.type || undefined, + description: description || undefined, + createdBy, + }).returning(); + + results.push(newAttachment); + } + }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag(`techSalesRfq-${techSalesRfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { data: results, error: null }; + } catch (err) { + console.error("기술영업 RFQ 첨부파일 생성 오류:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ 첨부파일 조회 + */ +export async function getTechSalesRfqAttachments(techSalesRfqId: number) { + unstable_noStore(); + try { + const attachments = await db.query.techSalesAttachments.findMany({ + where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), + orderBy: [desc(techSalesAttachments.createdAt)], + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }); + + return { data: attachments, error: null }; + } catch (err) { + console.error("기술영업 RFQ 첨부파일 조회 오류:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ 첨부파일 삭제 + */ +export async function deleteTechSalesRfqAttachment(attachmentId: number) { + unstable_noStore(); + try { + // 첨부파일 정보 조회 + const attachment = await db.query.techSalesAttachments.findFirst({ + where: eq(techSalesAttachments.id, attachmentId), + }); + + if (!attachment) { + return { data: null, error: "첨부파일을 찾을 수 없습니다." }; + } + + // RFQ 상태 확인 + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists + columns: { id: true, status: true } + }); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + // 편집 가능한 상태 확인 + if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { + return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." }; + } + + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // DB에서 레코드 삭제 + const deletedAttachment = await tx.delete(techSalesAttachments) + .where(eq(techSalesAttachments.id, attachmentId)) + .returning(); + + // 파일 시스템에서 파일 삭제 + try { + const path = await import("path"); + const fs = await import("fs/promises"); + + const absolutePath = path.join(process.cwd(), "public", attachment.filePath); + await fs.unlink(absolutePath); + } catch (fileError) { + console.warn("파일 삭제 실패:", fileError); + // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행 + } + + return deletedAttachment[0]; + }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { data: result, error: null }; + } catch (err) { + console.error("기술영업 RFQ 첨부파일 삭제 오류:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제) + */ +export async function processTechSalesRfqAttachments(params: { + techSalesRfqId: number + newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[] + deleteAttachmentIds: number[] + createdBy: number +}) { + unstable_noStore(); + try { + const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params; + + // RFQ 존재 및 상태 확인 + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, techSalesRfqId), + columns: { id: true, status: true } + }); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { + return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." }; + } + + const results = { + uploaded: [] as typeof techSalesAttachments.$inferSelect[], + deleted: [] as typeof techSalesAttachments.$inferSelect[], + }; + + await db.transaction(async (tx) => { + const path = await import("path"); + const fs = await import("fs/promises"); + const { randomUUID } = await import("crypto"); + + // 1. 삭제할 첨부파일 처리 + if (deleteAttachmentIds.length > 0) { + const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({ + where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})` + }); + + for (const attachment of attachmentsToDelete) { + // DB에서 레코드 삭제 + const [deletedAttachment] = await tx.delete(techSalesAttachments) + .where(eq(techSalesAttachments.id, attachment.id)) + .returning(); + + results.deleted.push(deletedAttachment); + + // 파일 시스템에서 파일 삭제 + try { + const absolutePath = path.join(process.cwd(), "public", attachment.filePath); + await fs.unlink(absolutePath); + } catch (fileError) { + console.warn("파일 삭제 실패:", fileError); + } + } + } + + // 2. 새 파일 업로드 처리 + if (newFiles.length > 0) { + const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); + await fs.mkdir(rfqDir, { recursive: true }); + + for (const { file, attachmentType, description } of newFiles) { + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 고유 파일명 생성 + 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); + + // DB에 첨부파일 레코드 생성 + const [newAttachment] = await tx.insert(techSalesAttachments).values({ + techSalesRfqId, + attachmentType, + fileName: uniqueName, + originalFileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + fileSize: file.size, + fileType: file.type || undefined, + description: description || undefined, + createdBy, + }).returning(); + + results.uploaded.push(newAttachment); + } + } + }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag(`techSalesRfq-${techSalesRfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { + data: results, + error: null, + message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료` + }; + } catch (err) { + console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +// ======================================== +// 메일 발송 관련 함수들 +// ======================================== + +/** + * 벤더 견적 제출 확인 메일 발송 (벤더용) + */ +export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) { + try { + // 견적서 정보 조회 + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + rfq: { + with: { + item: true, + biddingProject: true, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }, + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + } + } + }); + + if (!quotation || !quotation.rfq || !quotation.vendor) { + console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); + return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; + } + + // 벤더 사용자들 조회 + const vendorUsers = await db.query.users.findMany({ + where: eq(users.companyId, quotation.vendor.id), + columns: { + id: true, + email: true, + name: true, + language: true + } + }); + + const vendorEmails = vendorUsers + .filter(user => user.email) + .map(user => user.email) + .join(", "); + + if (!vendorEmails) { + console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); + return { success: false, error: "벤더 이메일 주소가 없습니다" }; + } + + // 프로젝트 정보 준비 + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + + // 시리즈 정보 처리 + const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ + sersNo: series.sersNo, + klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', + scDt: series.scDt, + lcDt: series.lcDt, + dlDt: series.dlDt, + dockNo: series.dockNo, + dockNm: series.dockNm, + projNo: series.projNo, + post1: series.post1, + })) : []; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: vendorUsers[0]?.language || "ko", + quotation: { + id: quotation.id, + currency: quotation.currency, + totalPrice: quotation.totalPrice, + validUntil: quotation.validUntil, + submittedAt: quotation.submittedAt, + remark: quotation.remark, + }, + rfq: { + id: quotation.rfq.id, + code: quotation.rfq.rfqCode, + title: quotation.rfq.item?.itemName || '', + projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', + projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + dueDate: quotation.rfq.dueDate, + materialCode: quotation.rfq.materialCode, + description: quotation.rfq.remark, + }, + vendor: { + id: quotation.vendor.id, + code: quotation.vendor.vendorCode, + name: quotation.vendor.vendorName, + }, + project: { + name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', + shipCount: projectInfo.projMsrm || 0, + ownerName: projectInfo.kunnrNm || '', + className: projectInfo.cls1Nm || '', + shipModelName: projectInfo.pmodelNm || '', + }, + series: seriesInfo, + manager: { + name: quotation.rfq.createdByUser?.name || '', + email: quotation.rfq.createdByUser?.email || '', + }, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', + companyName: 'Samsung Heavy Industries', + year: new Date().getFullYear(), + }; + + // 이메일 발송 + await sendEmail({ + to: vendorEmails, + subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.item?.itemName || '견적 요청'}`, + template: 'tech-sales-quotation-submitted-vendor-ko', + context: emailContext, + }); + + console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`); + return { success: true }; + } catch (error) { + console.error("벤더 견적 제출 확인 메일 발송 오류:", error); + return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; + } +} + +/** + * 벤더 견적 접수 알림 메일 발송 (담당자용) + */ +export async function sendQuotationSubmittedNotificationToManager(quotationId: number) { + try { + // 견적서 정보 조회 + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + rfq: { + with: { + item: true, + biddingProject: true, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }, + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + } + } + }); + + if (!quotation || !quotation.rfq || !quotation.vendor) { + console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); + return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; + } + + const manager = quotation.rfq.createdByUser; + if (!manager?.email) { + console.warn("담당자 이메일 주소가 없습니다"); + return { success: false, error: "담당자 이메일 주소가 없습니다" }; + } + + // 프로젝트 정보 준비 + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + + // 시리즈 정보 처리 + const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ + sersNo: series.sersNo, + klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', + scDt: series.scDt, + lcDt: series.lcDt, + dlDt: series.dlDt, + dockNo: series.dockNo, + dockNm: series.dockNm, + projNo: series.projNo, + post1: series.post1, + })) : []; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: "ko", + quotation: { + id: quotation.id, + currency: quotation.currency, + totalPrice: quotation.totalPrice, + validUntil: quotation.validUntil, + submittedAt: quotation.submittedAt, + remark: quotation.remark, + }, + rfq: { + id: quotation.rfq.id, + code: quotation.rfq.rfqCode, + title: quotation.rfq.item?.itemName || '', + projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', + projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + dueDate: quotation.rfq.dueDate, + materialCode: quotation.rfq.materialCode, + description: quotation.rfq.remark, + }, + vendor: { + id: quotation.vendor.id, + code: quotation.vendor.vendorCode, + name: quotation.vendor.vendorName, + }, + project: { + name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', + shipCount: projectInfo.projMsrm || 0, + ownerName: projectInfo.kunnrNm || '', + className: projectInfo.cls1Nm || '', + shipModelName: projectInfo.pmodelNm || '', + }, + series: seriesInfo, + manager: { + name: manager.name || '', + email: manager.email, + }, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp', + companyName: 'Samsung Heavy Industries', + year: new Date().getFullYear(), + }; + + // 이메일 발송 + await sendEmail({ + to: manager.email, + subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`, + template: 'tech-sales-quotation-submitted-manager-ko', + context: emailContext, + }); + + console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`); + return { success: true }; + } catch (error) { + console.error("담당자 견적 접수 알림 메일 발송 오류:", error); + return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; + } +} + +/** + * 벤더 견적 선택 알림 메일 발송 + */ +export async function sendQuotationAcceptedNotification(quotationId: number) { + try { + // 견적서 정보 조회 + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + rfq: { + with: { + item: true, + biddingProject: true, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }, + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + } + } + }); + + if (!quotation || !quotation.rfq || !quotation.vendor) { + console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); + return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; + } + + // 벤더 사용자들 조회 + const vendorUsers = await db.query.users.findMany({ + where: eq(users.companyId, quotation.vendor.id), + columns: { + id: true, + email: true, + name: true, + language: true + } + }); + + const vendorEmails = vendorUsers + .filter(user => user.email) + .map(user => user.email) + .join(", "); + + if (!vendorEmails) { + console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); + return { success: false, error: "벤더 이메일 주소가 없습니다" }; + } + + // 프로젝트 정보 준비 + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + + // 시리즈 정보 처리 + const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ + sersNo: series.sersNo, + klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', + scDt: series.scDt, + lcDt: series.lcDt, + dlDt: series.dlDt, + dockNo: series.dockNo, + dockNm: series.dockNm, + projNo: series.projNo, + post1: series.post1, + })) : []; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: vendorUsers[0]?.language || "ko", + quotation: { + id: quotation.id, + currency: quotation.currency, + totalPrice: quotation.totalPrice, + validUntil: quotation.validUntil, + acceptedAt: quotation.acceptedAt, + remark: quotation.remark, + }, + rfq: { + id: quotation.rfq.id, + code: quotation.rfq.rfqCode, + title: quotation.rfq.item?.itemName || '', + projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', + projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + dueDate: quotation.rfq.dueDate, + materialCode: quotation.rfq.materialCode, + description: quotation.rfq.remark, + }, + vendor: { + id: quotation.vendor.id, + code: quotation.vendor.vendorCode, + name: quotation.vendor.vendorName, + }, + project: { + name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', + shipCount: projectInfo.projMsrm || 0, + ownerName: projectInfo.kunnrNm || '', + className: projectInfo.cls1Nm || '', + shipModelName: projectInfo.pmodelNm || '', + }, + series: seriesInfo, + manager: { + name: quotation.rfq.createdByUser?.name || '', + email: quotation.rfq.createdByUser?.email || '', + }, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', + companyName: 'Samsung Heavy Industries', + year: new Date().getFullYear(), + }; + + // 이메일 발송 + await sendEmail({ + to: vendorEmails, + subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`, + template: 'tech-sales-quotation-accepted-ko', + context: emailContext, + }); + + console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`); + return { success: true }; + } catch (error) { + console.error("벤더 견적 선택 알림 메일 발송 오류:", error); + return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; + } +} + +/** + * 벤더 견적 거절 알림 메일 발송 + */ +export async function sendQuotationRejectedNotification(quotationId: number) { + try { + // 견적서 정보 조회 + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + rfq: { + with: { + item: true, + biddingProject: true, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }, + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + } + } + }); + + if (!quotation || !quotation.rfq || !quotation.vendor) { + console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); + return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; + } + + // 벤더 사용자들 조회 + const vendorUsers = await db.query.users.findMany({ + where: eq(users.companyId, quotation.vendor.id), + columns: { + id: true, + email: true, + name: true, + language: true + } + }); + + const vendorEmails = vendorUsers + .filter(user => user.email) + .map(user => user.email) + .join(", "); + + if (!vendorEmails) { + console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); + return { success: false, error: "벤더 이메일 주소가 없습니다" }; + } + + // 프로젝트 정보 준비 + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + + // 시리즈 정보 처리 + const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ + sersNo: series.sersNo, + klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', + scDt: series.scDt, + lcDt: series.lcDt, + dlDt: series.dlDt, + dockNo: series.dockNo, + dockNm: series.dockNm, + projNo: series.projNo, + post1: series.post1, + })) : []; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: vendorUsers[0]?.language || "ko", + quotation: { + id: quotation.id, + currency: quotation.currency, + totalPrice: quotation.totalPrice, + validUntil: quotation.validUntil, + rejectionReason: quotation.rejectionReason, + remark: quotation.remark, + }, + rfq: { + id: quotation.rfq.id, + code: quotation.rfq.rfqCode, + title: quotation.rfq.item?.itemName || '', + projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', + projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + dueDate: quotation.rfq.dueDate, + materialCode: quotation.rfq.materialCode, + description: quotation.rfq.remark, + }, + vendor: { + id: quotation.vendor.id, + code: quotation.vendor.vendorCode, + name: quotation.vendor.vendorName, + }, + project: { + name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', + shipCount: projectInfo.projMsrm || 0, + ownerName: projectInfo.kunnrNm || '', + className: projectInfo.cls1Nm || '', + shipModelName: projectInfo.pmodelNm || '', + }, + series: seriesInfo, + manager: { + name: quotation.rfq.createdByUser?.name || '', + email: quotation.rfq.createdByUser?.email || '', + }, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', + companyName: 'Samsung Heavy Industries', + year: new Date().getFullYear(), + }; + + // 이메일 발송 + await sendEmail({ + to: vendorEmails, + subject: `[견적 검토 결과] ${quotation.rfq.rfqCode} - 견적 검토 결과를 안내드립니다`, + template: 'tech-sales-quotation-rejected-ko', + context: emailContext, + }); + + console.log(`벤더 견적 거절 알림 메일 발송 완료: ${vendorEmails}`); + return { success: true }; + } catch (error) { + console.error("벤더 견적 거절 알림 메일 발송 오류:", error); + return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; + } +} + +// ==================== Vendor Communication 관련 ==================== + +export interface TechSalesAttachment { + id: number + fileName: string + fileSize: number + fileType: string | null // <- null 허용 + filePath: string + uploadedAt: Date +} + +export interface TechSalesComment { + id: number + rfqId: number + vendorId: number | null // null 허용으로 변경 + userId?: number | null // null 허용으로 변경 + content: string + isVendorComment: boolean | null // null 허용으로 변경 + createdAt: Date + updatedAt: Date + userName?: string | null // null 허용으로 변경 + vendorName?: string | null // null 허용으로 변경 + attachments: TechSalesAttachment[] + isRead: boolean | null // null 허용으로 변경 +} + +/** + * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 + * + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + * @returns 코멘트 목록 + */ +export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> { + if (!vendorId) { + return [] + } + + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + // 코멘트 쿼리 + const comments = await db.query.techSalesRfqComments.findMany({ + where: and( + eq(techSalesRfqComments.rfqId, rfqId), + eq(techSalesRfqComments.vendorId, vendorId) + ), + orderBy: [techSalesRfqComments.createdAt], + with: { + user: { + columns: { + name: true + } + }, + vendor: { + columns: { + vendorName: true + } + }, + attachments: true, + } + }) + + // 결과 매핑 + return comments.map(comment => ({ + id: comment.id, + rfqId: comment.rfqId, + vendorId: comment.vendorId, + userId: comment.userId || undefined, + content: comment.content, + isVendorComment: comment.isVendorComment, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + userName: comment.user?.name, + vendorName: comment.vendor?.vendorName, + isRead: comment.isRead, + attachments: comment.attachments.map(att => ({ + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize, + fileType: att.fileType, + filePath: att.filePath, + uploadedAt: att.uploadedAt + })) + })) + } catch (error) { + console.error('techSales 벤더 코멘트 가져오기 오류:', error) + throw error + } +} + +/** + * 코멘트를 읽음 상태로 표시하는 서버 액션 + * + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + */ +export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> { + if (!vendorId) { + return + } + + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + // 벤더가 작성한 읽지 않은 코멘트 업데이트 + await db.update(techSalesRfqComments) + .set({ isRead: true }) + .where( + and( + eq(techSalesRfqComments.rfqId, rfqId), + eq(techSalesRfqComments.vendorId, vendorId), + eq(techSalesRfqComments.isVendorComment, true), + eq(techSalesRfqComments.isRead, false) + ) + ) + + // 캐시 무효화 + revalidateTag(`tech-sales-rfq-${rfqId}-comments`) + } catch (error) { + console.error('techSales 메시지 읽음 표시 오류:', error) + throw error + } }
\ No newline at end of file |
