From ad29c8d9dc5ce3f57d1e994e84603edcdb961c12 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Dec 2025 06:16:32 +0000 Subject: (최겸) 기술영업 drm 첨부 시 결재 로직 적용 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/approval-actions.ts | 42 +++--- lib/techsales-rfq/approval-handlers.ts | 150 ++++++++++----------- lib/techsales-rfq/service.ts | 27 ++-- .../table/tech-sales-rfq-attachments-sheet.tsx | 143 +++++++++++++++++++- 4 files changed, 238 insertions(+), 124 deletions(-) (limited to 'lib') diff --git a/lib/techsales-rfq/approval-actions.ts b/lib/techsales-rfq/approval-actions.ts index 335be760..cf914592 100644 --- a/lib/techsales-rfq/approval-actions.ts +++ b/lib/techsales-rfq/approval-actions.ts @@ -172,17 +172,19 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string } } /** - * 기술영업 RFQ 재발송 결재 상신 + * 기술영업 RFQ DRM 첨부 해제 결재 상신 * - * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송을 위한 결재 상신 + * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 DRM 해제를 위한 결재 상신 */ export async function requestRfqResendWithDrmApproval(data: { rfqId: number; rfqCode?: string; - drmFiles: Array<{ - file: File; - attachmentType: string; - description?: string; + drmAttachmentIds: number[]; + drmAttachments: Array<{ + id: number; + fileName?: string | null; + fileSize?: number | null; + attachmentType?: string | null; }>; applicationReason: string; currentUser: { @@ -197,18 +199,18 @@ export async function requestRfqResendWithDrmApproval(data: { throw new Error('Knox EP ID가 필요합니다.'); } - console.log('[RFQ Resend Approval] Starting resend approval process'); - console.log('[RFQ Resend Approval] RFQ ID:', data.rfqId); - console.log('[RFQ Resend Approval] DRM Files:', data.drmFiles.length); + console.log('[RFQ DRM Unlock Approval] Starting DRM unlock approval process'); + console.log('[RFQ DRM Unlock Approval] RFQ ID:', data.rfqId); + console.log('[RFQ DRM Unlock Approval] DRM Attachments:', data.drmAttachmentIds.length); try { // 템플릿 변수 매핑 const variables = await mapTechSalesRfqSendToTemplateVariables({ - attachments: data.drmFiles.map(f => ({ - fileName: f.file.name, - fileSize: f.file.size, + attachments: data.drmAttachments.map(att => ({ + fileName: att.fileName, + fileSize: att.fileSize, })), - vendorNames: [], // 기존 벤더 목록은 후처리에서 조회 + vendorNames: [], applicationReason: data.applicationReason, }); @@ -216,7 +218,7 @@ export async function requestRfqResendWithDrmApproval(data: { const approvalPayload = { rfqId: data.rfqId, rfqCode: data.rfqCode, - drmFiles: data.drmFiles, + drmAttachmentIds: data.drmAttachmentIds, currentUser: { id: data.currentUser.id, name: data.currentUser.name, @@ -231,8 +233,8 @@ export async function requestRfqResendWithDrmApproval(data: { 'tech_sales_rfq_resend_with_drm', // 핸들러 키 approvalPayload, { - title: `DRM 파일 재발송 결재 - ${data.rfqCode || 'RFQ'}`, - description: `이미 발송된 RFQ에 ${data.drmFiles.length}개의 DRM 파일이 추가되어 재발송을 요청합니다.`, + title: `DRM 파일 해제 결재 - ${data.rfqCode || 'RFQ'}`, + description: `발송 완료된 RFQ에 추가된 DRM 첨부파일 ${data.drmAttachmentIds.length}개 해제를 요청합니다.`, templateName: '암호화해제 신청', variables, approvers: data.approvers, @@ -247,20 +249,20 @@ export async function requestRfqResendWithDrmApproval(data: { const result = await saga.execute(); - console.log('[RFQ Resend Approval] ✅ Resend approval submitted successfully'); + console.log('[RFQ DRM Unlock Approval] ✅ DRM unlock approval submitted successfully'); return { success: true, ...result, - message: `재발송 결재가 상신되었습니다. (결재 ID: ${result.approvalId})`, + message: `DRM 해제 결재가 상신되었습니다. (결재 ID: ${result.approvalId})`, }; } catch (error) { - console.error('[RFQ Resend Approval] ❌ Failed to submit resend approval:', error); + console.error('[RFQ DRM Unlock Approval] ❌ Failed to submit DRM unlock approval:', error); throw new Error( error instanceof Error ? error.message - : 'RFQ 재발송 결재 상신에 실패했습니다.' + : 'RFQ DRM 해제 결재 상신에 실패했습니다.' ); } } diff --git a/lib/techsales-rfq/approval-handlers.ts b/lib/techsales-rfq/approval-handlers.ts index 979096b7..9b7b61ff 100644 --- a/lib/techsales-rfq/approval-handlers.ts +++ b/lib/techsales-rfq/approval-handlers.ts @@ -8,7 +8,7 @@ import db from '@/db/db'; import { eq, and } from 'drizzle-orm'; -import { techSalesAttachments, techSalesRfqs, TECH_SALES_RFQ_STATUSES } from '@/db/schema/techSales'; +import { techSalesAttachments } from '@/db/schema/techSales'; import { sendTechSalesRfqToVendors } from './service'; import { decryptWithServerAction } from '@/components/drm/drmUtils'; import { saveFile, deleteFile } from '@/lib/file-stroage'; @@ -127,18 +127,14 @@ export async function sendTechSalesRfqWithApprovalInternal(payload: { } /** - * 기술영업 RFQ 재발송 핸들러 (결재 승인 후 자동 실행) - * - * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송 처리 + * 기술영업 RFQ DRM 해제 핸들러 (결재 승인 후 자동 실행) + * + * 이미 발송된 RFQ에 추가된 DRM 첨부파일을 복호화하여 저장한다. */ export async function resendTechSalesRfqWithDrmInternal(payload: { rfqId: number; rfqCode?: string; - drmFiles: Array<{ - file: File; - attachmentType: string; - description?: string; - }>; + drmAttachmentIds?: number[]; currentUser: { id: string | number; name?: string | null; @@ -146,104 +142,94 @@ export async function resendTechSalesRfqWithDrmInternal(payload: { epId?: string | null; }; }) { - console.log('[TechSales RFQ Resend Handler] Starting DRM resend after approval'); - console.log('[TechSales RFQ Resend Handler] RFQ ID:', payload.rfqId); - console.log('[TechSales RFQ Resend Handler] DRM Files:', payload.drmFiles.length); + console.log('[TechSales RFQ DRM Unlock Handler] Starting DRM unlock after approval'); + console.log('[TechSales RFQ DRM Unlock Handler] RFQ ID:', payload.rfqId); + console.log('[TechSales RFQ DRM Unlock Handler] DRM Attachment Ids:', payload.drmAttachmentIds?.length || 0); try { - // 1. 새로 추가된 DRM 파일들 복호화 및 저장 - for (const drmFile of payload.drmFiles) { + // 1) DRM 첨부 조회 (지정된 ID가 있으면 필터링) + const drmAttachments = await db.query.techSalesAttachments.findMany({ + where: and( + eq(techSalesAttachments.techSalesRfqId, payload.rfqId), + eq(techSalesAttachments.drmEncrypted, true), + ) + }); + + const filteredAttachments = payload.drmAttachmentIds && payload.drmAttachmentIds.length > 0 + ? drmAttachments.filter(att => payload.drmAttachmentIds?.includes(att.id)) + : drmAttachments; + + if (filteredAttachments.length === 0) { + console.log('[TechSales RFQ DRM Unlock Handler] No DRM attachments to process'); + return { success: true, message: '처리할 DRM 첨부파일이 없습니다.' }; + } + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_URL; + + for (const attachment of filteredAttachments) { try { + // DRM 파일 다운로드 + let fileUrl = attachment.filePath; + const absoluteUrl = `${baseUrl}${fileUrl}`; + console.log(`[TechSales RFQ DRM Unlock Handler] Fetching file from: ${absoluteUrl}`); + + const fileResponse = await fetch(absoluteUrl); + if (!fileResponse.ok) { + console.error(`[TechSales RFQ DRM Unlock Handler] Failed to fetch file: ${absoluteUrl} (status: ${fileResponse.status})`); + continue; + } + + const fileBlob = await fileResponse.blob(); + const file = new File([fileBlob], attachment.originalFileName); + // DRM 복호화 - console.log(`[TechSales RFQ Resend Handler] Decrypting: ${drmFile.file.name}`); - const decryptedBuffer = await decryptWithServerAction(drmFile.file); - - // 복호화된 파일 저장 + console.log(`[TechSales RFQ DRM Unlock Handler] Decrypting: ${attachment.originalFileName}`); + const decryptedBuffer = await decryptWithServerAction(file); + + // 복호화된 파일로 재저장 (기존 파일은 보존) const saveResult = await saveFile({ - file: new File([decryptedBuffer], drmFile.file.name), + file: new File([decryptedBuffer], attachment.originalFileName), directory: `techsales-rfq/${payload.rfqId}`, userId: String(payload.currentUser.id), }); - + if (!saveResult.success) { throw new Error(saveResult.error || '파일 저장 실패'); } - - // 기존 DRM 파일 레코드 찾기 및 업데이트 - const existingAttachment = await db.query.techSalesAttachments.findFirst({ - where: and( - eq(techSalesAttachments.techSalesRfqId, payload.rfqId), - eq(techSalesAttachments.originalFileName, drmFile.file.name), - eq(techSalesAttachments.drmEncrypted, true) - ) - }); - - if (existingAttachment) { - // 기존 파일 삭제 - await deleteFile(existingAttachment.filePath); - - // DB 업데이트: drmEncrypted = false, filePath 업데이트 - await db.update(techSalesAttachments) - .set({ - drmEncrypted: false, - filePath: saveResult.publicPath!, - fileName: saveResult.fileName!, - }) - .where(eq(techSalesAttachments.id, existingAttachment.id)); - } else { - // 새 레코드 생성 - await db.insert(techSalesAttachments).values({ - techSalesRfqId: payload.rfqId, - attachmentType: drmFile.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", - fileName: saveResult.fileName!, - originalFileName: drmFile.file.name, + + // DB 업데이트: drmEncrypted = false, filePath/fileName 갱신 + await db.update(techSalesAttachments) + .set({ + drmEncrypted: false, filePath: saveResult.publicPath!, + fileName: saveResult.fileName!, fileSize: decryptedBuffer.byteLength, - fileType: drmFile.file.type || undefined, - description: drmFile.description, - drmEncrypted: false, // DRM 해제됨 - createdBy: Number(payload.currentUser.id), - }); - } - - console.log(`[TechSales RFQ Resend Handler] ✅ Decrypted and saved: ${drmFile.file.name}`); + }) + .where(eq(techSalesAttachments.id, attachment.id)); + + console.log(`[TechSales RFQ DRM Unlock Handler] ✅ Decrypted and saved: ${attachment.originalFileName}`); } catch (error) { - console.error(`[TechSales RFQ Resend Handler] ❌ Failed to decrypt ${drmFile.file.name}:`, error); + console.error(`[TechSales RFQ DRM Unlock Handler] ❌ Failed to decrypt ${attachment.originalFileName}:`, error); throw error; } } - // 2. RFQ 상태를 "RFQ Sent"로 변경 - await db.update(techSalesRfqs) - .set({ - status: TECH_SALES_RFQ_STATUSES.RFQ_SENT, - updatedAt: new Date(), - }) - .where(eq(techSalesRfqs.id, payload.rfqId)); - - // 3. RFQ 재발송 실행 (기존에 할당된 모든 벤더에게) - const { getTechSalesRfqVendors } = await import('./service'); - const vendorsResult = await getTechSalesRfqVendors(payload.rfqId); - const vendorIds = vendorsResult.data?.map(v => v.vendorId) || []; - - const sendResult = await sendTechSalesRfqToVendors({ - rfqId: payload.rfqId, - vendorIds: vendorIds, - currentUser: payload.currentUser, - }); - - console.log('[TechSales RFQ Resend Handler] ✅ RFQ resent successfully after DRM decryption'); + // 캐시 무효화 + const { revalidateTag, revalidatePath } = await import('next/cache'); + revalidateTag("techSalesRfqs"); + revalidateTag(`techSalesRfq-${payload.rfqId}`); + revalidatePath("/partners/techsales"); return { success: true, - ...sendResult, + message: 'DRM 첨부파일이 해제되었습니다.', }; } catch (error) { - console.error('[TechSales RFQ Resend Handler] ❌ Failed to resend RFQ:', error); + console.error('[TechSales RFQ DRM Unlock Handler] ❌ Failed to unlock DRM:', error); throw new Error( error instanceof Error - ? `RFQ 재발송 실패: ${error.message}` - : 'RFQ 재발송 중 오류가 발생했습니다.' + ? `DRM 해제 실패: ${error.message}` + : 'DRM 해제 중 오류가 발생했습니다.' ); } } diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 8ce41cba..e52c7a36 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -1971,12 +1971,6 @@ export async function processTechSalesRfqAttachments(params: { drmFiles.push({ file, attachmentType, description }); } } - - // RFQ 상태가 "RFQ Sent"이고 DRM 파일이 추가된 경우 재발송 결재 트리거 - if (rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT && drmFiles.length > 0) { - // 트랜잭션 외부에서 처리하기 위해 에러로 전달 - throw new Error("DRM_FILE_ADDED_TO_SENT_RFQ"); - } } }); @@ -1990,20 +1984,19 @@ export async function processTechSalesRfqAttachments(params: { return { data: results, error: null, - message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료` + message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`, + requiresDrmApproval: rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT && results.uploaded.some(att => att.drmEncrypted), + drmAttachments: results.uploaded + .filter(att => att.drmEncrypted) + .map(att => ({ + id: att.id, + fileName: att.originalFileName || att.fileName, + fileSize: att.fileSize, + attachmentType: att.attachmentType, + })), }; } catch (err) { console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err); - - // DRM 파일 추가로 인한 재발송 결재 필요 에러 - if (err instanceof Error && err.message === "DRM_FILE_ADDED_TO_SENT_RFQ") { - return { - data: null, - error: "DRM_FILE_ADDED_TO_SENT_RFQ", - rfqId: techSalesRfqId, - }; - } - return { data: null, error: getErrorMessage(err) }; } } diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx index f2ae1084..95d4e1a3 100644 --- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -49,6 +49,10 @@ import { import prettyBytes from "pretty-bytes" import { formatDate } from "@/lib/utils" import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" +import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers" +import { requestRfqResendWithDrmApproval } from "@/lib/techsales-rfq/approval-actions" +import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog" +import { ApprovalPreviewDialog } from "@/lib/approval/client" import { useSession } from "next-auth/react" const MAX_FILE_SIZE = 6e8 // 600MB @@ -130,6 +134,18 @@ export function TechSalesRfqAttachmentsSheet({ }: TechSalesRfqAttachmentsSheetProps) { const [isPending, setIsPending] = React.useState(false) const session = useSession() + const [drmApprovalData, setDrmApprovalData] = React.useState<{ + attachments: Array<{ + id: number + fileName?: string | null + fileSize?: number | null + attachmentType?: string | null + }> + } | null>(null) + const [applicationReason, setApplicationReason] = React.useState("") + const [approvalPreviewVariables, setApprovalPreviewVariables] = React.useState | null>(null) + const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false) + const [showApprovalPreview, setShowApprovalPreview] = React.useState(false) // 파일 다운로드 핸들러 const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => { @@ -266,6 +282,77 @@ export function TechSalesRfqAttachmentsSheet({ removeExisting(index) }, [removeExisting]) + // DRM 해제 결재 - 신청사유 확인 + const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => { + if (!drmApprovalData || !rfq) { + toast.error("결재 데이터가 없습니다.") + return + } + + try { + const templateVariables = await mapTechSalesRfqSendToTemplateVariables({ + attachments: drmApprovalData.attachments.map(att => ({ + fileName: att.fileName, + fileSize: att.fileSize, + })), + vendorNames: [], + applicationReason: reason, + }) + + setApplicationReason(reason) + setApprovalPreviewVariables(templateVariables) + setShowApprovalPreview(true) + } catch (error) { + console.error("템플릿 변수 생성 실패:", error) + toast.error("결재 문서 생성에 실패했습니다.") + } + }, [drmApprovalData, rfq]) + + // DRM 해제 결재 - 상신 + const handleApprovalConfirm = React.useCallback(async (approvalData: { + approvers: string[]; + title: string; + description?: string; + }) => { + if (!drmApprovalData || !rfq) { + toast.error("결재 데이터가 없습니다.") + return + } + + if (!session.data?.user?.epId) { + toast.error("Knox EP ID가 필요합니다.") + return + } + + try { + const result = await requestRfqResendWithDrmApproval({ + rfqId: rfq.id, + rfqCode: rfq.rfqCode || undefined, + drmAttachmentIds: drmApprovalData.attachments.map(att => att.id), + drmAttachments: drmApprovalData.attachments, + applicationReason, + currentUser: { + id: parseInt(session.data?.user.id || "0"), + epId: session.data.user.epId || null, + name: session.data.user.name || undefined, + email: session.data.user.email || undefined, + }, + approvers: approvalData.approvers, + }) + + toast.success(result.message || "DRM 해제 결재가 상신되었습니다.") + setShowApprovalPreview(false) + setShowApplicationReasonDialog(false) + setDrmApprovalData(null) + setApprovalPreviewVariables(null) + // 결재 상신 후 시트 닫기 + props.onOpenChange?.(false) + } catch (error) { + console.error("DRM 해제 결재 상신 실패:", error) + toast.error(error instanceof Error ? error.message : "DRM 해제 결재 상신에 실패했습니다.") + } + }, [applicationReason, drmApprovalData, rfq, session.data?.user?.email, session.data?.user?.epId, session.data?.user?.id, session.data?.user?.name, props]) + // Handle form submission const onSubmit = async (data: AttachmentsFormValues) => { if (!rfq) { @@ -314,11 +401,26 @@ export function TechSalesRfqAttachmentsSheet({ } else if (deletedCount > 0) { successMessage = `${deletedCount}개 파일이 삭제되었습니다.` } - - toast.success(successMessage) - - // 다이얼로그 자동 닫기 - props.onOpenChange?.(false) + + const requiresDrmApproval = (result as any)?.requiresDrmApproval + const drmAttachments = ((result as any)?.drmAttachments as ExistingTechSalesAttachment[] | undefined) || [] + + if (requiresDrmApproval) { + toast.success(`${successMessage} DRM 첨부파일이 포함되어 결재가 필요합니다.`) + setDrmApprovalData({ + attachments: drmAttachments.map(att => ({ + id: att.id, + fileName: att.originalFileName || att.fileName, + fileSize: att.fileSize, + attachmentType: att.attachmentType, + })) + }) + setShowApplicationReasonDialog(true) + } else { + toast.success(successMessage) + // 다이얼로그 자동 닫기 + props.onOpenChange?.(false) + } // // 즉시 첨부파일 목록 새로고침 // const refreshResult = await getTechSalesRfqAttachments(rfq.id) @@ -565,6 +667,37 @@ export function TechSalesRfqAttachmentsSheet({ + + {/* DRM 첨부 결재 - 신청사유 입력 */} + {drmApprovalData && ( + + )} + + {/* DRM 첨부 결재 - 미리보기 */} + {drmApprovalData && approvalPreviewVariables && ( + + )} ) } \ No newline at end of file -- cgit v1.2.3