// lib/tbe-last/service.ts 'use server' import { unstable_cache } from "next/cache"; import db from "@/db/db"; import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; import { tbeLastView, tbeDocumentsView } from "@/db/schema"; import { rfqPrItems } from "@/db/schema/rfqLast"; import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import path from "path" import fs from "fs/promises" import { sendEmail } from "../mail/sendEmail"; // ========================================== // 1. TBE 세션 목록 조회 // ========================================== export async function getAllTBELast(input: GetTBELastSchema) { return unstable_cache( async () => { // 페이징 const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); const limit = input.perPage ?? 10; // 고급 필터 const advancedWhere = filterColumns({ table: tbeLastView, filters: input.filters ?? [], joinOperator: input.joinOperator ?? "and", }); // 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( sql`${tbeLastView.sessionCode} ILIKE ${s}`, sql`${tbeLastView.rfqCode} ILIKE ${s}`, sql`${tbeLastView.vendorName} ILIKE ${s}`, sql`${tbeLastView.vendorCode} ILIKE ${s}`, sql`${tbeLastView.projectCode} ILIKE ${s}`, sql`${tbeLastView.projectName} ILIKE ${s}`, sql`${tbeLastView.packageNo} ILIKE ${s}`, sql`${tbeLastView.packageName} ILIKE ${s}` ); } // 최종 WHERE const finalWhere = and(advancedWhere, globalWhere); // 정렬 const orderBy = input.sort?.length ? input.sort.map((s) => { const col = (tbeLastView as any)[s.id]; return s.desc ? desc(col) : asc(col); }) : [desc(tbeLastView.createdAt)]; // 메인 SELECT const [rows, total] = await db.transaction(async (tx) => { const data = await tx .select() .from(tbeLastView) .where(finalWhere) .orderBy(...orderBy) .offset(offset) .limit(limit); const [{ count }] = await tx .select({ count: sql`count(*)`.as("count") }) .from(tbeLastView) .where(finalWhere); return [data, Number(count)]; }); const pageCount = Math.ceil(total / limit); return { data: rows, pageCount }; }, [JSON.stringify(input)], { revalidate: 60, tags: ["tbe-last-sessions"], } )(); } // ========================================== // 2. TBE 세션 상세 조회 // ========================================== export async function getTBESessionDetail(sessionId: number) { // return unstable_cache( // async () => { // 세션 기본 정보 const [session] = await db .select() .from(tbeLastView) .where(eq(tbeLastView.tbeSessionId, sessionId)) .limit(1); if (!session) { return null; } // PR 아이템 목록 const prItems = await db .select() .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, session.rfqId)) .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem)); // 문서 목록 (구매자 + 벤더) const documents = await db .select() .from(tbeDocumentsView) .where(eq(tbeDocumentsView.tbeSessionId, sessionId)) .orderBy( sql`CASE document_source WHEN 'buyer' THEN 0 ELSE 1 END`, asc(tbeDocumentsView.documentName) ); // PDFTron 코멘트 통계 const comments = await db .select({ documentReviewId: rfqLastTbePdftronComments.documentReviewId, totalCount: sql`count(*)`.as("total_count"), openCount: sql`sum(case when status = 'open' then 1 else 0 end)`.as("open_count"), }) .from(rfqLastTbePdftronComments) .innerJoin( rfqLastTbeDocumentReviews, eq(rfqLastTbePdftronComments.documentReviewId, rfqLastTbeDocumentReviews.id) ) .where(eq(rfqLastTbeDocumentReviews.tbeSessionId, sessionId)) .groupBy(rfqLastTbePdftronComments.documentReviewId); // 문서별 코멘트 수 매핑 const commentsByDocumentId = new Map( comments.map(c => [c.documentReviewId, { totalCount: c.totalCount, openCount: c.openCount }]) ); // 문서에 코멘트 정보 추가 const documentsWithComments = documents.map(doc => ({ ...doc, comments: doc.documentReviewId ? commentsByDocumentId.get(doc.documentReviewId) || { totalCount: 0, openCount: 0 } : { totalCount: 0, openCount: 0 } })); return { session, prItems, documents: documentsWithComments, }; // }, // [`tbe-session-${sessionId}`], // { // revalidate: 60, // tags: [`tbe-session-${sessionId}`], // } // )(); } // ========================================== // 3. 문서별 PDFTron 코멘트 조회 // ========================================== export async function getDocumentComments(documentReviewId: number) { const comments = await db .select({ id: rfqLastTbePdftronComments.id, pdftronAnnotationId: rfqLastTbePdftronComments.pdftronAnnotationId, pageNumber: rfqLastTbePdftronComments.pageNumber, commentText: rfqLastTbePdftronComments.commentText, commentCategory: rfqLastTbePdftronComments.commentCategory, severity: rfqLastTbePdftronComments.severity, status: rfqLastTbePdftronComments.status, createdBy: rfqLastTbePdftronComments.createdBy, createdByType: rfqLastTbePdftronComments.createdByType, createdAt: rfqLastTbePdftronComments.createdAt, resolvedBy: rfqLastTbePdftronComments.resolvedBy, resolvedAt: rfqLastTbePdftronComments.resolvedAt, resolutionNote: rfqLastTbePdftronComments.resolutionNote, replies: rfqLastTbePdftronComments.replies, }) .from(rfqLastTbePdftronComments) .where(eq(rfqLastTbePdftronComments.documentReviewId, documentReviewId)) .orderBy(asc(rfqLastTbePdftronComments.pageNumber), desc(rfqLastTbePdftronComments.createdAt)); return comments; } // ========================================== // 5. 벤더 문서 업로드 // ========================================== export async function uploadVendorDocument( sessionId: number, file: { fileName: string; originalFileName: string; filePath: string; fileSize: number; fileType: string; documentType: string; description?: string; } ) { const [document] = await db .insert(rfqLastTbeVendorDocuments) .values({ tbeSessionId: sessionId, documentType: file.documentType as any, fileName: file.fileName, originalFileName: file.originalFileName, filePath: file.filePath, fileSize: file.fileSize, fileType: file.fileType, description: file.description, reviewRequired: true, reviewStatus: "pending", submittedBy: 1, // TODO: 실제 사용자 ID submittedAt: new Date(), }) .returning(); return document; } interface UpdateEvaluationData { evaluationResult?: "Acceptable" | "Acceptable with Comment" | "Not Acceptable" conditionalRequirements?: string conditionsFulfilled?: boolean technicalSummary?: string commercialSummary?: string overallRemarks?: string approvalRemarks?: string status?: "준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소" } export async function updateTbeEvaluation( tbeSessionId: number, data: UpdateEvaluationData ) { try { const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, error: "인증이 필요합니다" } } const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id // 현재 TBE 세션 조회 const [currentTbeSession] = await db .select() .from(rfqLastTbeSessions) .where(eq(rfqLastTbeSessions.id, tbeSessionId)) .limit(1) if (!currentTbeSession) { return { success: false, error: "TBE 세션을 찾을 수 없습니다" } } // 업데이트 데이터 준비 const updateData: any = { updatedBy: userId, updatedAt: new Date() } // 평가 결과 관련 필드 if (data.evaluationResult !== undefined) { updateData.evaluationResult = data.evaluationResult } // 조건부 승인 관련 (Acceptable with Comment인 경우) if (data.evaluationResult === "Acceptable with Comment") { if (data.conditionalRequirements !== undefined) { updateData.conditionalRequirements = data.conditionalRequirements } if (data.conditionsFulfilled !== undefined) { updateData.conditionsFulfilled = data.conditionsFulfilled } } else if (data.evaluationResult === "Acceptable") { // Acceptable인 경우 조건부 필드 초기화 updateData.conditionalRequirements = null updateData.conditionsFulfilled = true } else if (data.evaluationResult === "Not Acceptable") { // Not Acceptable인 경우 조건부 필드 초기화 updateData.conditionalRequirements = null updateData.conditionsFulfilled = false } // 평가 요약 필드 if (data.technicalSummary !== undefined) { updateData.technicalSummary = data.technicalSummary } if (data.commercialSummary !== undefined) { updateData.commercialSummary = data.commercialSummary } if (data.overallRemarks !== undefined) { updateData.overallRemarks = data.overallRemarks } // 승인 관련 필드 if (data.approvalRemarks !== undefined) { updateData.approvalRemarks = data.approvalRemarks updateData.approvedBy = userId updateData.approvedAt = new Date() } // 상태 업데이트 if (data.status !== undefined) { updateData.status = data.status // 완료 상태로 변경 시 종료일 설정 if (data.status === "완료") { updateData.actualEndDate = new Date() } } // TBE 세션 업데이트 const [updated] = await db .update(rfqLastTbeSessions) .set(updateData) .where(eq(rfqLastTbeSessions.id, tbeSessionId)) .returning() // 캐시 초기화 revalidateTag(`tbe-session-${tbeSessionId}`) revalidateTag(`tbe-sessions`) // RFQ 관련 캐시도 초기화 if (currentTbeSession.rfqsLastId) { revalidateTag(`rfq-${currentTbeSession.rfqsLastId}`) } return { success: true, data: updated, message: "평가가 성공적으로 저장되었습니다" } } catch (error) { console.error("Failed to update TBE evaluation:", error) return { success: false, error: error instanceof Error ? error.message : "평가 저장에 실패했습니다" } } } export async function getTbeVendorDocuments(tbeSessionId: number) { try { const documents = await db .select({ id: rfqLastTbeVendorDocuments.id, documentName: rfqLastTbeVendorDocuments.originalFileName, documentType: rfqLastTbeVendorDocuments.documentType, fileName: rfqLastTbeVendorDocuments.fileName, fileSize: rfqLastTbeVendorDocuments.fileSize, fileType: rfqLastTbeVendorDocuments.fileType, documentNo: rfqLastTbeVendorDocuments.documentNo, revisionNo: rfqLastTbeVendorDocuments.revisionNo, issueDate: rfqLastTbeVendorDocuments.issueDate, description: rfqLastTbeVendorDocuments.description, submittedAt: rfqLastTbeVendorDocuments.submittedAt, // 검토 정보는 rfqLastTbeDocumentReviews에서 가져옴 reviewStatus: rfqLastTbeDocumentReviews.reviewStatus, reviewComments: rfqLastTbeDocumentReviews.reviewComments, reviewedAt: rfqLastTbeDocumentReviews.reviewedAt, requiresRevision: rfqLastTbeDocumentReviews.requiresRevision, technicalCompliance: rfqLastTbeDocumentReviews.technicalCompliance, qualityAcceptable: rfqLastTbeDocumentReviews.qualityAcceptable, }) .from(rfqLastTbeVendorDocuments) .leftJoin( rfqLastTbeDocumentReviews, and( eq(rfqLastTbeDocumentReviews.vendorAttachmentId, rfqLastTbeVendorDocuments.id), eq(rfqLastTbeDocumentReviews.documentSource, "vendor") ) ) .where(eq(rfqLastTbeVendorDocuments.tbeSessionId, tbeSessionId)) .orderBy(rfqLastTbeVendorDocuments.submittedAt) // 문서 정보 매핑 (reviewStatus는 이미 한글로 저장되어 있음) const mappedDocuments = documents.map(doc => ({ ...doc, reviewStatus: doc.reviewStatus || "미검토", // null인 경우 기본값 reviewRequired: doc.requiresRevision || false, // UI 호환성을 위해 필드명 매핑 })) return { success: true, documents: mappedDocuments, } } catch (error) { console.error("Failed to fetch vendor documents:", error) return { success: false, error: "벤더 문서를 불러오는데 실패했습니다", documents: [], } } } // 리뷰 상태 매핑 함수 function mapReviewStatus(status: string | null): string { const statusMap: Record = { "pending": "미검토", "reviewing": "검토중", "approved": "승인", "rejected": "반려", } return status ? (statusMap[status] || status) : "미검토" } interface DocumentInfo { documentId: number documentReviewId: number documentName: string filePath: string documentType: string documentSource: string reviewStatus?: string } interface SessionInfo { sessionId: number sessionTitle: string buyerName: string vendorName: string } interface SendDocumentsEmailParams { to: string[] cc?: string[] documents: DocumentInfo[] comments?: string sessionInfo: SessionInfo } export async function sendDocumentsEmail({ to, cc, documents, comments, sessionInfo }: SendDocumentsEmailParams) { try { // 사용자 인증 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, error: "인증이 필요합니다" } } // 첨부 파일 준비 const attachments = await Promise.all( documents.map(async (doc) => { try { // 실제 파일 경로 구성 (프로젝트 구조에 맞게 조정 필요) const isDev = process.env.NODE_ENV === 'development'; const filePath = isDev ? path.join(process.cwd(), 'public', doc.filePath) :path.join(`${process.env.NAS_PATH}`, doc.filePath) // 파일 존재 확인 await fs.access(filePath) // 파일 읽기 const content = await fs.readFile(filePath) return { filename: doc.documentName, content: content, encoding: 'base64' } } catch (error) { console.error(`Failed to attach file: ${doc.documentName}`, error) // 파일을 찾을 수 없는 경우 건너뛰기 return null } }) ) // null 값 필터링 const validAttachments = attachments.filter(att => att !== null) // 이메일 전송 const result = await sendEmail({ to: to.join(", "), cc: cc?.join(", "), template: "document-share", // 템플릿 이름 context: { senderName: session.user.name || "TBE User", senderEmail: session.user.email, sessionTitle: sessionInfo.sessionTitle, sessionId: sessionInfo.sessionId, buyerName: sessionInfo.buyerName, vendorName: sessionInfo.vendorName, documentCount: documents.length, documents: documents.map(doc => ({ name: doc.documentName, type: doc.documentType, source: doc.documentSource, reviewStatus: doc.reviewStatus || "미검토", reviewStatusClass: getReviewStatusClass(doc.reviewStatus), })), comments: comments || "", hasComments: !!comments, language: "ko", // 한국어로 설정 year: new Date().getFullYear(), }, attachments: validAttachments as any }) return { success: true, data: result } } catch (error) { console.error("Failed to send documents email:", error) return { success: false, error: error instanceof Error ? error.message : "Failed to send email" } } } // 리뷰 상태에 따른 CSS 클래스 반환 function getReviewStatusClass(status?: string): string { switch (status) { case "승인": return "approved" case "반려": return "rejected" case "보류": return "pending" case "검토중": return "reviewing" default: return "unreviewed" } }