summaryrefslogtreecommitdiff
path: root/lib/rfq-last/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/service.ts')
-rw-r--r--lib/rfq-last/service.ts461
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,