summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/service.ts
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-05-29 05:12:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-29 05:37:04 +0000
commite484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch)
treed18133dde99e6feb773c95d04f7e79715ab24252 /lib/techsales-rfq/service.ts
parent37f55540833c2d5894513eca9fc8f7c6233fc2d2 (diff)
(김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업)
Diffstat (limited to 'lib/techsales-rfq/service.ts')
-rw-r--r--lib/techsales-rfq/service.ts1008
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