From 4863ba5d336297dddcc8d6d4b414beceb5559742 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Dec 2025 03:03:26 +0000 Subject: (최겸) 기술영업 rfq eml첨부기능 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/service.ts | 169 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) (limited to 'lib/techsales-rfq/service.ts') diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index cf4d02e2..13d0bbce 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -3662,7 +3662,8 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number updatedAt: techSalesVendorQuotationAttachments.updatedAt, }) .from(techSalesVendorQuotationAttachments) - .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId)) + .where(and(eq(techSalesVendorQuotationAttachments.quotationId, quotationId), + eq(techSalesVendorQuotationAttachments.isVendorUpload, true))) .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); return { data: attachments }; @@ -3679,6 +3680,172 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number )(); } +/** + * 기술영업 RFQ 기준 벤더 견적서 요약 목록 조회 (eml 첨부 전용) + */ +export async function getTechSalesVendorQuotationsForRfq(rfqId: number) { + unstable_noStore(); + try { + const quotations = await db + .select({ + id: techSalesVendorQuotations.id, + vendorId: techSalesVendorQuotations.vendorId, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + quotationVersion: techSalesVendorQuotations.quotationVersion, + status: techSalesVendorQuotations.status, + }) + .from(techSalesVendorQuotations) + .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id)) + .where(eq(techSalesVendorQuotations.rfqId, rfqId)) + .orderBy( + asc(techVendors.vendorName), + asc(techSalesVendorQuotations.id) + ); + + return { data: quotations, error: null }; + } catch (error) { + console.error("기술영업 RFQ 벤더 견적서 목록 조회 오류:", error); + return { data: [], error: getErrorMessage(error) }; + } +} + +/** + * 기술영업 벤더 견적서 eml 첨부파일 조회 (isVendorUpload = false) + */ +export async function getTechSalesVendorQuotationEmlAttachments(quotationId: number) { + unstable_noStore(); + try { + const attachments = await db + .select({ + id: techSalesVendorQuotationAttachments.id, + quotationId: techSalesVendorQuotationAttachments.quotationId, + revisionId: techSalesVendorQuotationAttachments.revisionId, + fileName: techSalesVendorQuotationAttachments.fileName, + originalFileName: techSalesVendorQuotationAttachments.originalFileName, + fileSize: techSalesVendorQuotationAttachments.fileSize, + fileType: techSalesVendorQuotationAttachments.fileType, + filePath: techSalesVendorQuotationAttachments.filePath, + description: techSalesVendorQuotationAttachments.description, + uploadedBy: techSalesVendorQuotationAttachments.uploadedBy, + vendorId: techSalesVendorQuotationAttachments.vendorId, + isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload, + createdAt: techSalesVendorQuotationAttachments.createdAt, + updatedAt: techSalesVendorQuotationAttachments.updatedAt, + }) + .from(techSalesVendorQuotationAttachments) + .where( + and( + eq(techSalesVendorQuotationAttachments.quotationId, quotationId), + eq(techSalesVendorQuotationAttachments.isVendorUpload, false) + ) + ) + .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); + + return { data: attachments, error: null }; + } catch (error) { + console.error("기술영업 벤더 견적서 eml 첨부파일 조회 오류:", error); + return { data: [], error: getErrorMessage(error) }; + } +} + +/** + * 기술영업 벤더 견적서 eml 첨부파일 업로드/삭제 처리 + * - isVendorUpload = false 로 저장 (메일 등 별도 전달 문서 보관용) + */ +export async function processTechSalesVendorQuotationEmlAttachments(params: { + quotationId: number; + newFiles?: { file: File; description?: string }[]; + deleteAttachmentIds?: number[]; + uploadedBy: number; + revisionId?: number; +}) { + unstable_noStore(); + const { quotationId, newFiles = [], deleteAttachmentIds = [], uploadedBy, revisionId } = params; + + try { + // 견적서 확인 + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + columns: { id: true, rfqId: true, quotationVersion: true }, + }); + + if (!quotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; + } + + const targetRevisionId = revisionId ?? quotation.quotationVersion ?? 0; + + await db.transaction(async (tx) => { + // 삭제 처리 (벤더 업로드 파일은 삭제하지 않음) + if (deleteAttachmentIds.length > 0) { + const deletable = await tx.query.techSalesVendorQuotationAttachments.findMany({ + where: inArray(techSalesVendorQuotationAttachments.id, deleteAttachmentIds), + }); + + for (const attachment of deletable) { + if (attachment.isVendorUpload) { + throw new Error("벤더가 업로드한 파일은 여기서 삭제할 수 없습니다."); + } + + await tx + .delete(techSalesVendorQuotationAttachments) + .where(eq(techSalesVendorQuotationAttachments.id, attachment.id)); + + try { + deleteFile(attachment.filePath); + } catch (fileError) { + console.warn("eml 첨부파일 삭제 중 파일 시스템 오류:", fileError); + } + } + } + + // 업로드 처리 + if (newFiles.length > 0) { + for (const { file, description } of newFiles) { + const saveResult = await saveFile({ + file, + directory: `techsales-quotations/${quotationId}/eml`, + userId: uploadedBy.toString(), + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || "파일 저장에 실패했습니다."); + } + + await tx.insert(techSalesVendorQuotationAttachments).values({ + quotationId, + revisionId: targetRevisionId, + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName || file.name, + fileSize: file.size, + fileType: file.type || undefined, + filePath: saveResult.publicPath!, + description: description || null, + uploadedBy, + isVendorUpload: false, + }); + } + } + }); + + // 캐시 무효화 + revalidateTag(`quotation-${quotationId}`); + revalidateTag("quotation-attachments"); + revalidateTag("techSalesVendorQuotations"); + if (quotation.rfqId) { + revalidateTag(`techSalesRfq-${quotation.rfqId}`); + } + revalidateTag("techSalesRfqs"); + + const refreshed = await getTechSalesVendorQuotationEmlAttachments(quotationId); + return { data: refreshed.data, error: refreshed.error }; + } catch (error) { + console.error("기술영업 벤더 견적서 eml 첨부파일 처리 오류:", error); + return { data: null, error: getErrorMessage(error) }; + } +} + /** * 특정 리비전의 견적서 첨부파일 조회 */ -- cgit v1.2.3 From 198cbcf93b20d3849705dcdbba439326d82b0cae Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Dec 2025 03:24:15 +0000 Subject: (최겸) 기술영업 rfq 프로젝트 숨기기 기능 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 1 + lib/techsales-rfq/repository.ts | 1 + lib/techsales-rfq/service.ts | 28 ++++++++++++++++++++-- .../table/detail-table/rfq-detail-table.tsx | 3 ++- .../vendor-contact-selection-dialog.tsx | 22 +++++++++++++---- .../vendor-response/detail/project-info-tab.tsx | 13 +++++++--- .../table/vendor-quotations-table-columns.tsx | 7 ++++-- 7 files changed, 63 insertions(+), 12 deletions(-) (limited to 'lib/techsales-rfq/service.ts') diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 9bf61452..4cdaf90d 100644 --- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts @@ -296,6 +296,7 @@ export async function mapECCBiddingHeaderToBidding( // PR 정보 prNumber, // 첫번째 PR의 ZREQ_FN 값 hasPrDocument: false, // PR문서는 POS를 말하는 것으로 보임. + plant: eccHeader.WERKS || null, // 플랜트 코드(WERKS) // 상태 및 설정 status: 'bidding_generated', // 입찰생성 상태 diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index e6138651..61072d3f 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -94,6 +94,7 @@ export async function selectTechSalesRfqsWithJoin( // 담당자 및 비고 picCode: techSalesRfqs.picCode, + hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors, remark: techSalesRfqs.remark, cancelReason: techSalesRfqs.cancelReason, description: techSalesRfqs.description, diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 13d0bbce..8ce41cba 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -557,6 +557,7 @@ export async function sendTechSalesRfqToVendors(input: { email?: string | null; epId?: string | null; }; + hideProjectInfoForVendors?: boolean; }) { unstable_noStore(); try { @@ -573,6 +574,7 @@ export async function sendTechSalesRfqToVendors(input: { materialCode: true, description: true, rfqType: true, + hideProjectInfoForVendors: true, }, with: { biddingProject: true, @@ -604,6 +606,23 @@ export async function sendTechSalesRfqToVendors(input: { } const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT; + const effectiveHideProjectInfo = + typeof input.hideProjectInfoForVendors === "boolean" + ? input.hideProjectInfoForVendors + : rfq.hideProjectInfoForVendors ?? false; + + if ( + typeof input.hideProjectInfoForVendors === "boolean" && + input.hideProjectInfoForVendors !== rfq.hideProjectInfoForVendors + ) { + await db + .update(techSalesRfqs) + .set({ + hideProjectInfoForVendors: input.hideProjectInfoForVendors, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, input.rfqId)); + } // 현재 사용자 정보 조회 const sender = await db.query.users.findFirst({ @@ -728,6 +747,9 @@ export async function sendTechSalesRfqToVendors(input: { const rfqItemsResult = await getTechSalesRfqItems(rfq.id); const rfqItems = rfqItemsResult.data || []; + const projectNameForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.projNm || ""; + const projectCodeForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.pspid || ""; + // 이메일 컨텍스트 구성 const emailContext = { language: language, @@ -735,8 +757,8 @@ export async function sendTechSalesRfqToVendors(input: { id: rfq.id, code: rfq.rfqCode, title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '', - projectCode: rfq.biddingProject?.pspid || '', - projectName: rfq.biddingProject?.projNm || '', + projectCode: projectCodeForVendor, + projectName: projectNameForVendor, description: rfq.remark || '', dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A', materialCode: rfq.materialCode || '', @@ -990,6 +1012,7 @@ export async function getTechSalesVendorQuotation(quotationId: number) { projMsrm: quotation.projMsrm, ptypeNm: quotation.ptypeNm, } : null, + hideProjectInfoForVendors: quotation.hideProjectInfoForVendors ?? false, }, // 벤더 정보 @@ -1414,6 +1437,7 @@ export async function getVendorQuotations(input: { dueDate: techSalesRfqs.dueDate, rfqStatus: techSalesRfqs.status, description: techSalesRfqs.description, + hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors, // 프로젝트 정보 (직접 조인) projNm: biddingProjects.projNm, // 아이템 개수 diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index aee15594..d8ced6f8 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -257,7 +257,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps contactId: number; contactEmail: string; contactName: string; - }>) => { + }>, options?: { hideProjectInfoForVendors?: boolean }) => { if (!selectedRfqId) { toast.error("선택된 RFQ가 없습니다."); return; @@ -301,6 +301,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps name: session.data.user.name || undefined, email: session.data.user.email || undefined, }, + hideProjectInfoForVendors: options?.hideProjectInfoForVendors, }); if (result.success) { diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx index d83394bb..8daa9be7 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx @@ -49,7 +49,10 @@ interface VendorContactSelectionDialogProps { onOpenChange: (open: boolean) => void vendorIds: number[] rfqId?: number // RFQ ID 추가 - onSendRfq: (selectedContacts: SelectedContact[]) => Promise + onSendRfq: ( + selectedContacts: SelectedContact[], + options: { hideProjectInfoForVendors: boolean } + ) => Promise } export function VendorContactSelectionDialog({ @@ -63,6 +66,7 @@ export function VendorContactSelectionDialog({ const [selectedContacts, setSelectedContacts] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isSending, setIsSending] = useState(false) + const [hideProjectInfoForVendors, setHideProjectInfoForVendors] = useState(false) // 벤더 contact 정보 조회 useEffect(() => { @@ -77,6 +81,7 @@ export function VendorContactSelectionDialog({ setVendorsWithContacts({}) setSelectedContacts([]) setIsLoading(false) + setHideProjectInfoForVendors(false) } }, [open]) @@ -177,7 +182,7 @@ export function VendorContactSelectionDialog({ try { setIsSending(true) - await onSendRfq(selectedContacts) + await onSendRfq(selectedContacts, { hideProjectInfoForVendors }) onOpenChange(false) } catch (error) { console.error("RFQ 발송 오류:", error) @@ -328,8 +333,17 @@ export function VendorContactSelectionDialog({
-
- 총 {selectedContacts.length}명의 연락처가 선택됨 +
+
+ 총 {selectedContacts.length}명의 연락처가 선택됨 +
+