diff options
Diffstat (limited to 'lib/rfq-last/service.ts')
| -rw-r--r-- | lib/rfq-last/service.ts | 461 |
1 files changed, 444 insertions, 17 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index f2894577..2baf1f46 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -65,7 +65,11 @@ export async function getRfqs(input: GetRfqsSchema) { if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); - + // dueDate 필터 디버깅 + const dueDateFilters = input.filters.filter(f => f.id === 'dueDate'); + if (dueDateFilters.length > 0) { + console.log("dueDate 필터 상세:", dueDateFilters); + } try { advancedWhere = filterColumns({ table: rfqsLastView, @@ -74,6 +78,10 @@ export async function getRfqs(input: GetRfqsSchema) { }); console.log("필터 조건 생성 완료"); + // dueDate 필터가 포함된 경우 SQL 쿼리 확인 + if (dueDateFilters.length > 0) { + console.log("advancedWhere SQL:", advancedWhere); + } } catch (error) { console.error("필터 조건 생성 오류:", error); advancedWhere = undefined; @@ -313,6 +321,7 @@ interface CreateGeneralRfqInput { rfqTitle: string; dueDate: Date; picUserId: number; + projectId?: number; remark?: string; items: Array<{ itemCode: string; @@ -371,6 +380,9 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { status: "RFQ 생성", dueDate: dueDate, // 마감일 기본값 설정 + // 프로젝트 정보 (선택사항) + projectId: input.projectId || null, + // 대표 아이템 정보 itemCode: representativeItem.itemCode, itemName: representativeItem.itemName, @@ -393,8 +405,8 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { const prItemsData = input.items.map((item, index) => ({ rfqsLastId: newRfq.id, rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ... - prItem: `${index + 1}`.padStart(3, '0'), - prNo: rfqCode, // RFQ 코드를 PR 번호로 사용 + prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정 + prNo: null, // 일반견적에서는 PR 번호를 null로 설정 materialCode: item.itemCode, materialDescription: item.itemName, @@ -2469,7 +2481,46 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { throw error; } } +/** + * RFQ 발송용 이메일 템플릿 자동 선택 + */ +export async function getRfqEmailTemplate(): Promise<{ slug: string; name: string; category: string } | null> { + try { + // 1. 템플릿 목록 조회 + const templates = await db + .select({ + slug: templateDetailView.slug, + name: templateDetailView.name, + category: templateDetailView.category, + isActive: templateDetailView.isActive, + }) + .from(templateDetailView) + .where(eq(templateDetailView.isActive, true)) + .orderBy(templateDetailView.name); + + // 2. RFQ 또는 견적 관련 템플릿 찾기 (우선순위: category > name) + let selectedTemplate = null; + + // 우선 category가 'rfq' 또는 'quotation'인 템플릿 찾기 + selectedTemplate = templates.find(t => + t.category === 'rfq' || t.category === 'quotation' + ); + + // 없으면 이름에 '견적' 또는 'rfq'가 포함된 템플릿 찾기 + if (!selectedTemplate) { + selectedTemplate = templates.find(t => + t.name.toLowerCase().includes('견적') || + t.name.toLowerCase().includes('rfq') || + t.name.toLowerCase().includes('quotation') + ); + } + return selectedTemplate || null; + } catch (error) { + console.error("RFQ 이메일 템플릿 조회 실패:", error); + return null; + } +} /** * SendRfqDialog용 간단한 정보 조회 */ @@ -2646,6 +2697,14 @@ export async function getRfqSendData(rfqId: number): Promise<RfqSendData> { quotationType: rfq.rfqType || undefined, evaluationApply: true, // 기본값 또는 별도 필드 contractType: undefined, // 필요시 추가 + // 시스템 정보 + formattedDueDate: rfq.dueDate ? rfq.dueDate.toLocaleDateString('ko-KR') : undefined, + systemName: "SHI EVCP", + hasAttachments: attachments.length > 0, + attachmentsCount: attachments.length, + language: "ko", + companyName: "삼성중공업", + now: new Date(), }; return { @@ -2857,6 +2916,12 @@ export interface SendRfqParams { vendors: VendorForSend[]; attachmentIds: number[]; message?: string; + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; + hasToSendEmail?: boolean; // 이메일 발송 여부 } export async function sendRfqToVendors({ @@ -2865,14 +2930,9 @@ export async function sendRfqToVendors({ vendors, attachmentIds, message, - generatedPdfs -}: SendRfqParams & { - generatedPdfs?: Array<{ - key: string; - buffer: number[]; - fileName: string; - }>; -}) { + generatedPdfs, + hasToSendEmail = true +}: SendRfqParams) { const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다."); @@ -2909,7 +2969,8 @@ export async function sendRfqToVendors({ picInfo, emailAttachments, designAttachments, - generatedPdfs + generatedPdfs, + hasToSendEmail }); // 6. RFQ 상태 업데이트 @@ -3093,7 +3154,8 @@ async function processVendors({ picInfo, emailAttachments, designAttachments, - generatedPdfs + generatedPdfs, + hasToSendEmail }: { rfqId: number; rfqData: any; @@ -3103,6 +3165,7 @@ async function processVendors({ emailAttachments: any[]; designAttachments: any[]; generatedPdfs?: any[]; + hasToSendEmail?: boolean; }) { const results = []; const errors = []; @@ -3130,7 +3193,8 @@ async function processVendors({ picInfo, contractsDir, generatedPdfs, - designAttachments + designAttachments, + hasToSendEmail }); }); @@ -3170,7 +3234,8 @@ async function processSingleVendor({ picInfo, contractsDir, generatedPdfs, - designAttachments + designAttachments, + hasToSendEmail }: any) { const isResend = vendor.isResend || false; const sendVersion = (vendor.sendVersion || 0) + 1; @@ -3218,6 +3283,19 @@ async function processSingleVendor({ currentUser, designAttachments }); + // 이메일 발송 처리 (사용자가 선택한 경우에만) + let emailSent = null; + if (hasToSendEmail) { + emailSent = await handleRfqSendEmail({ + tx, + rfqId, + rfqData, + vendor, + newRfqDetail, + currentUser, + picInfo + }); + } return { result: { @@ -3227,7 +3305,8 @@ async function processSingleVendor({ responseId: vendorResponse.id, isResend, sendVersion, - tbeSessionCreated: tbeSession + tbeSessionCreated: tbeSession, + emailSent }, contracts, tbeSession @@ -3683,7 +3762,289 @@ async function updateRfqStatus(rfqId: number, userId: number) { }) .where(eq(rfqsLast.id, rfqId)); } +async function handleRfqSendEmail({ + tx, + rfqId, + rfqData, + vendor, + newRfqDetail, + currentUser, + picInfo +}: any) { + try { + // 1. 이메일 수신자 정보 준비 + const emailRecipients = prepareEmailRecipients(vendor, picInfo.picEmail); + + // 2. RFQ 기본 정보 조회 (템플릿용) + const rfqBasicInfoResult = await getRfqBasicInfoAction(rfqId); + const rfqBasicInfo = rfqBasicInfoResult.success ? rfqBasicInfoResult.data : null; + + // 3. 프로젝트 정보 조회 + let projectInfo = null; + if (rfqData.projectId) { + projectInfo = await getProjectInfo(rfqData.projectId); + } + + // 4. PR Items 정보 조회 (주요 품목) + const [majorItem] = await tx + .select({ + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + prNo: rfqPrItems.prNo, + }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )) + .limit(1); + + // 5. RFQ 첨부파일 조회 + const rfqAttachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where(eq(rfqLastAttachments.rfqId, rfqId)); + + // 6. 이메일 제목 생성 (RFQ 타입에 따라) + const emailSubject = generateEmailSubject({ + rfqType: rfqData.rfqType, + projectName: projectInfo?.name || '', + rfqCode: rfqData.rfqCode, + packageName: rfqData.packageName || '', + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode + }); + + // 7. 이메일 본문용 컨텍스트 데이터 구성 + const emailContext = { + // 기본 정보 + language: "ko", + now: new Date(), + companyName: "삼성중공업", + siteName: "EVCP Portal", + + // RFQ 정보 + rfqId: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqTitle: rfqData.rfqTitle, + rfqType: rfqData.rfqType, + dueDate: rfqData.dueDate, + rfqDescription: rfqData.rfqTitle || `${rfqData.rfqCode} 견적 요청`, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: projectInfo?.code || '', + projectName: projectInfo?.name || '', + projectCompany: projectInfo?.customerName || '', + projectFlag: projectInfo?.flag || '', + projectSite: projectInfo?.site || '', + + // 패키지 정보 + packageNo: rfqData.packageNo || "MM03", + packageName: rfqData.packageName || "Deck Machinery", + packageDescription: `${rfqData.packageNo || 'MM03'} - ${rfqData.packageName || 'Deck Machinery'}`, + + // 품목 정보 + itemCode: rfqData.itemCode || '', + itemName: rfqData.itemName || '', + itemCount: 1, + materialGroup: majorItem?.materialCategory || "BE2101", + materialGroupDesc: majorItem?.materialDescription || "Combined Windlass & Mooring Winch", + + // 보증 정보 (기본값) + warrantyMonths: "35", + warrantyDescription: "선박 인도 후 35개월 시점까지 납품한 자재 또는 용역이 계약 내용과 동일함을 보증", + repairAdditionalMonths: "24", + repairDescription: "Repair 시 24개월 추가", + totalWarrantyMonths: "36", + totalWarrantyDescription: "총 인도 후 36개월을 넘지 않음", + + // 필수 제출 정보 + requiredDocuments: [ + "품목별 단가 및 중량", + "가격 기재/미기재 견적서(Priced/Unpriced Quotation)", + "설계 Technical Bid Evaluation(TBE) 자료", + "당사 PGS, SGS & POS에 대한 Deviation List" + ], + + // 계약 요구사항 + contractRequirements: { + hasNda: newRfqDetail.ndaYn, + hasGeneralGtc: newRfqDetail.generalGtcYn, + hasProjectGtc: newRfqDetail.projectGtcYn, + hasAgreement: newRfqDetail.agreementYn, + ndaDescription: "비밀유지계약서", + generalGtcDescription: "General GTC", + projectGtcDescription: "Project GTC", + agreementDescription: "기술자료 제공 동의서" + }, + + // 업체 정보 + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + vendorCountry: vendor.vendorCountry, + vendorEmail: vendor.vendorEmail, + vendorRepresentativeEmail: vendor.representativeEmail, + vendorCurrency: vendor.currency, + + // 담당자 정보 + picId: rfqData.picId, + picName: rfqData.picName, + picCode: rfqData.picCode, + picEmail: picInfo.picEmail, + picTeam: rfqData.picTeam, + engPicName: rfqData.EngPicName, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + prItemsCount: 1, + + // 시리즈 및 코드 정보 + series: rfqData.series, + smCode: rfqData.smCode, + + // 첨부파일 정보 + attachmentsCount: rfqAttachments.length, + hasAttachments: rfqAttachments.length > 0, + + // 설정 정보 + isDevelopment: process.env.NODE_ENV === 'development', + portalUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + systemName: "EVCP (Electronic Vendor Communication Portal)", + + // 추가 정보 + currentDate: new Date().toLocaleDateString('ko-KR'), + currentTime: new Date().toLocaleTimeString('ko-KR'), + formattedDueDate: new Date(rfqData.dueDate).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }) + }; + + // 8. 이메일 첨부파일 준비 + const emailAttachmentsList: Array<{ filename: string; content?: Buffer; path?: string }> = []; + + // RFQ 첨부파일 추가 + for (const { attachment, revision } of rfqAttachments) { + if (revision?.filePath) { + try { + const isProduction = process.env.NODE_ENV === "production"; + const cleanPath = revision.filePath.startsWith('/api/files') + ? revision.filePath.slice('/api/files'.length) + : revision.filePath; + + const fullPath = !isProduction + ? path.join(process.cwd(), `public`, cleanPath) + : path.join(`${process.env.NAS_PATH}`, cleanPath); + + const fileBuffer = await fs.readFile(fullPath); + emailAttachmentsList.push({ + filename: revision.originalFileName || `${attachment.attachmentType}_${attachment.serialNo}`, + content: fileBuffer + }); + } catch (error) { + console.error(`이메일 첨부파일 읽기 실패: ${cleanPath}`, error); + } + } + } + + // 9. 이메일 발송 + if (emailRecipients.to.length > 0) { + const isDevelopment = process.env.NODE_ENV === 'development'; + + await sendEmail({ + from: isDevelopment + ? (process.env.Email_From_Address ?? "no-reply@company.com") + : `"${picInfo.picName}" <${picInfo.picEmail}>`, + to: emailRecipients.to.join(", "), + cc: emailRecipients.cc.length > 0 ? emailRecipients.cc.join(", ") : undefined, + subject: emailSubject, + template: "custom-rfq-invitation", + context: emailContext, + attachments: emailAttachmentsList.length > 0 ? emailAttachmentsList : undefined, + }); + + // 10. 이메일 발송 상태 업데이트 + await tx + .update(rfqLastDetails) + .set({ + emailSentAt: new Date(), + emailSentTo: JSON.stringify(emailRecipients), + emailStatus: "sent", + lastEmailSentAt: new Date(), + emailResentCount: newRfqDetail.emailResentCount || 0, + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, newRfqDetail.id)); + + return { + success: true, + recipients: emailRecipients.to.length, + ccCount: emailRecipients.cc.length + }; + } + + return { + success: false, + error: "수신자 정보가 없습니다" + }; + + } catch (error) { + console.error(`이메일 발송 실패 (${vendor.vendorName}):`, error); + + // 이메일 발송 실패 상태 업데이트 + await tx + .update(rfqLastDetails) + .set({ + emailStatus: "failed", + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, newRfqDetail.id)); + + return { + success: false, + error: error instanceof Error ? error.message : "이메일 발송 실패" + }; + } +} + +// 이메일 제목 생성 함수 +function generateEmailSubject({ + rfqType, + projectName, + rfqCode, + packageName, + vendorName, + vendorCode +}: { + rfqType?: string; + projectName: string; + rfqCode: string; + packageName: string; + vendorName: string; + vendorCode?: string | null; +}) { + const typePrefix = rfqType === 'ITB' ? 'ITB' : + rfqType === 'RFQ' ? 'RFQ' : '일반견적'; + const vendorInfo = vendorCode ? `${vendorName} (${vendorCode})` : vendorName; + + return `[SHI ${typePrefix}] ${projectName} _ ${rfqCode} _ ${packageName} _ ${vendorInfo}`.trim(); +} export async function updateRfqDueDate( rfqId: number, newDueDate: Date | string, @@ -3954,7 +4315,73 @@ export async function updateRfqDueDate( } } +/** + * RFQ 벤더 응답 첨부파일 삭제 + */ +export async function deleteVendorResponseAttachment({ + attachmentId, + responseId, + userId +}: { + attachmentId: number; + responseId: number; + userId: number; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + + // 첨부파일이 해당 응답에 속하는지 확인 + const [attachment] = await db + .select() + .from(rfqLastVendorAttachments) + .where( + and( + eq(rfqLastVendorAttachments.id, attachmentId), + eq(rfqLastVendorAttachments.vendorResponseId, responseId) + ) + ) + .limit(1); + + if (!attachment) { + throw new Error("삭제할 첨부파일을 찾을 수 없습니다."); + } + + // 트랜잭션으로 삭제 + await db.transaction(async (tx) => { + // 첨부파일 삭제 + await tx + .delete(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.id, attachmentId)); + + // 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: "첨부파일삭제", + changeDetails: { + attachmentId, + attachmentType: attachment.attachmentType, + documentNo: attachment.documentNo, + fileName: attachment.fileName + }, + performedBy: userId, + }); + }); + return { + success: true, + message: "첨부파일이 삭제되었습니다." + }; + } catch (error) { + console.error("첨부파일 삭제 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다." + }; + } +} export async function deleteRfqVendor({ rfqId, detailId, |
