'use server' import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; import db from "@/db/db"; import { techSalesRfqs, techSalesVendorQuotations, techSalesAttachments, items, users, techSalesRfqComments } from "@/db/schema"; import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; import type { Filter } from "@/types/table"; import { selectTechSalesRfqsWithJoin, countTechSalesRfqsWithJoin, selectTechSalesVendorQuotationsWithJoin, countTechSalesVendorQuotationsWithJoin, selectTechSalesDashboardWithJoin } from "./repository"; import { GetTechSalesRfqsSchema } from "./validations"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { sendEmail } from "../mail/sendEmail"; import { formatDate, formatDateToQuarter } from "../utils"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 // eslint-disable-next-line @typescript-eslint/no-explicit-any type OrderByType = any; // 시리즈 스냅샷 타입 정의 interface SeriesSnapshot { pspid: string; sersNo: string; scDt?: string; klDt?: string; lcDt?: string; dlDt?: string; dockNo?: string; dockNm?: string; projNo?: string; post1?: string; } /** * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ... */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function generateRfqCodes(tx: any, count: number, year?: number): Promise { const currentYear = year || new Date().getFullYear(); const yearPrefix = `RFQ-${currentYear}-`; // 해당 연도의 가장 최근 RFQ 코드 조회 const latestRfq = await tx .select({ rfqCode: techSalesRfqs.rfqCode }) .from(techSalesRfqs) .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`)) .orderBy(desc(techSalesRfqs.rfqCode)) .limit(1); let nextNumber = 1; if (latestRfq.length > 0) { // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001) const lastCode = latestRfq[0].rfqCode; const numberPart = lastCode.split('-').pop(); if (numberPart) { const lastNumber = parseInt(numberPart, 10); if (!isNaN(lastNumber)) { nextNumber = lastNumber + 1; } } } // 요청된 개수만큼 순차적으로 코드 생성 const codes: string[] = []; for (let i = 0; i < count; i++) { const paddedNumber = (nextNumber + i).toString().padStart(3, '0'); codes.push(`${yearPrefix}${paddedNumber}`); } return codes; } /** * 기술영업 조선 RFQ 생성 액션 * * 받을 파라미터 (생성시 입력하는 것) * 1. RFQ 관련 * 2. 프로젝트 관련 * 3. 자재 관련 (자재그룹) * * 나머지 벤더, 첨부파일 등은 생성 이후 처리 */ export async function createTechSalesRfq(input: { // 프로젝트 관련 biddingProjectId: number; // 자재 관련 (자재그룹 코드들) materialGroupCodes: string[]; // 기본 정보 dueDate?: Date; remark?: string; createdBy: number; }) { unstable_noStore(); try { const results: typeof techSalesRfqs.$inferSelect[] = []; // 트랜잭션으로 처리 await db.transaction(async (tx) => { // 실제 프로젝트 정보 조회 const biddingProject = await tx.query.biddingProjects.findFirst({ where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) }); if (!biddingProject) { throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); } // 프로젝트 시리즈 정보 조회 const seriesInfo = await tx.query.projectSeries.findMany({ where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) }); // 프로젝트 스냅샷 생성 const projectSnapshot = { pspid: biddingProject.pspid, projNm: biddingProject.projNm || undefined, sector: biddingProject.sector || undefined, projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, kunnr: biddingProject.kunnr || undefined, kunnrNm: biddingProject.kunnrNm || undefined, cls1: biddingProject.cls1 || undefined, cls1Nm: biddingProject.cls1Nm || undefined, ptype: biddingProject.ptype || undefined, ptypeNm: biddingProject.ptypeNm || undefined, pmodelCd: biddingProject.pmodelCd || undefined, pmodelNm: biddingProject.pmodelNm || undefined, pmodelSz: biddingProject.pmodelSz || undefined, pmodelUom: biddingProject.pmodelUom || undefined, txt04: biddingProject.txt04 || undefined, txt30: biddingProject.txt30 || undefined, estmPm: biddingProject.estmPm || undefined, pspCreatedAt: biddingProject.createdAt, pspUpdatedAt: biddingProject.updatedAt, }; // 시리즈 스냅샷 생성 const seriesSnapshot = seriesInfo.map(series => ({ pspid: series.pspid, sersNo: series.sersNo.toString(), scDt: series.scDt || undefined, klDt: series.klDt || undefined, lcDt: series.lcDt || undefined, dlDt: series.dlDt || undefined, dockNo: series.dockNo || undefined, dockNm: series.dockNm || undefined, projNo: series.projNo || undefined, post1: series.post1 || undefined, })); // 각 자재그룹 코드별로 RFQ 생성 for (const materialCode of input.materialGroupCodes) { // RFQ 코드 생성 const rfqCode = await generateRfqCodes(tx, 1); // 기본 due date 설정 (7일 후) const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 기존 item 확인 또는 새로 생성 let itemId: number; const existingItem = await tx.query.items.findFirst({ where: (items, { eq }) => eq(items.itemCode, materialCode), columns: { id: true } }); if (existingItem) { // 기존 item 사용 itemId = existingItem.id; } else { // 새 item 생성 const [newItem] = await tx.insert(items).values({ itemCode: materialCode, itemName: `자재그룹 ${materialCode}`, description: `기술영업 자재그룹`, }).returning(); itemId = newItem.id; } // 새 기술영업 RFQ 작성 (스냅샷 포함) const [newRfq] = await tx.insert(techSalesRfqs).values({ rfqCode: rfqCode[0], itemId: itemId, biddingProjectId: input.biddingProjectId, materialCode, dueDate, remark: input.remark, createdBy: input.createdBy, updatedBy: input.createdBy, // 스냅샷 데이터 추가 projectSnapshot, seriesSnapshot, }).returning(); results.push(newRfq); } }); // 캐시 무효화 revalidateTag("techSalesRfqs"); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: results, error: null }; } catch (err) { console.error("Error creating RFQ:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 * 페이지네이션, 필터링, 정렬 등 지원 */ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 기본 필터 처리 - RFQFilterBox에서 오는 필터 const basicFilters = input.basicFilters || []; const basicJoinOperator = input.basicJoinOperator || "and"; // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 const advancedFilters = input.filters || []; const advancedJoinOperator = input.joinOperator || "and"; // 기본 필터 조건 생성 let basicWhere; if (basicFilters.length > 0) { basicWhere = filterColumns({ table: techSalesRfqs, filters: basicFilters, joinOperator: basicJoinOperator, }); } // 고급 필터 조건 생성 let advancedWhere; if (advancedFilters.length > 0) { advancedWhere = filterColumns({ table: techSalesRfqs, filters: advancedFilters, joinOperator: advancedJoinOperator, }); } // 전역 검색 조건 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), // JSON 필드 검색 sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`, sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm' ILIKE ${s}` ); } // 모든 조건 결합 const whereConditions = []; if (basicWhere) whereConditions.push(basicWhere); if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); // 조건이 있을 때만 and() 사용 const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 정렬 기준 설정 let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬 if (input.sort?.length) { // 안전하게 접근하여 정렬 기준 설정 orderBy = input.sort.map(item => { switch (item.id) { case 'id': return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; case 'rfqCode': return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; case 'status': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; case 'dueDate': return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; case 'createdAt': return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; case 'updatedAt': return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; default: return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; } }); } // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectTechSalesRfqsWithJoin(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countTechSalesRfqsWithJoin(tx, finalWhere); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount, total }; } catch (err) { console.error("Error fetching RFQs with join:", err); return { data: [], pageCount: 0, total: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 60, // 1분간 캐시 tags: ["techSalesRfqs"], } )(); } /** * 직접 조인을 사용하여 벤더 견적서 조회하는 함수 */ export async function getTechSalesVendorQuotationsWithJoin(input: { rfqId?: number; vendorId?: number; search?: string; filters?: Filter[]; sort?: { id: string; desc: boolean }[]; page: number; perPage: number; }) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 기본 필터 조건들 const whereConditions = []; // RFQ ID 필터 if (input.rfqId) { whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId)); } // 벤더 ID 필터 if (input.vendorId) { whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId)); } // 검색 조건 if (input.search) { const s = `%${input.search}%`; const searchCondition = or( ilike(techSalesVendorQuotations.currency, s), ilike(techSalesVendorQuotations.status, s) ); if (searchCondition) { whereConditions.push(searchCondition); } } // 고급 필터 처리 if (input.filters && input.filters.length > 0) { const filterWhere = filterColumns({ table: techSalesVendorQuotations, filters: input.filters as Filter[], joinOperator: "and", }); if (filterWhere) { whereConditions.push(filterWhere); } } // 최종 WHERE 조건 const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 정렬 기준 설정 let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)]; if (input.sort?.length) { orderBy = input.sort.map(item => { switch (item.id) { case 'id': return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; case 'status': return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; case 'currency': return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; case 'totalPrice': return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; case 'createdAt': return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; case 'updatedAt': return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; default: return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; } }); } // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectTechSalesVendorQuotationsWithJoin(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount, total }; } catch (err) { console.error("Error fetching vendor quotations with join:", err); return { data: [], pageCount: 0, total: 0 }; } }, [JSON.stringify(input)], { revalidate: 60, tags: [ "techSalesVendorQuotations", ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : []) ], } )(); } /** * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수 */ export async function getTechSalesDashboardWithJoin(input: { search?: string; filters?: Filter[]; sort?: { id: string; desc: boolean }[]; page: number; perPage: number; }) { unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음 try { const offset = (input.page - 1) * input.perPage; // Advanced filtering const advancedWhere = input.filters ? filterColumns({ table: techSalesRfqs, filters: input.filters as Filter[], joinOperator: 'and', }) : undefined; // Global search let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), // JSON 필드 검색 sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}` ); } const finalWhere = and( advancedWhere, globalWhere ); // 정렬 기준 설정 let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬 if (input.sort?.length) { // 안전하게 접근하여 정렬 기준 설정 orderBy = input.sort.map(item => { switch (item.id) { case 'id': return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; case 'rfqCode': return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'status': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; case 'dueDate': return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; case 'createdAt': return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; case 'updatedAt': return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; default: return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; } }); } // 트랜잭션 내부에서 Repository 호출 const data = await db.transaction(async (tx) => { return await selectTechSalesDashboardWithJoin(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); }); return { data, success: true }; } catch (err) { console.error("Error fetching dashboard data with join:", err); return { data: [], success: false, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ에 벤더 추가 (단일) */ export async function addVendorToTechSalesRfq(input: { rfqId: number; vendorId: number; createdBy: number; }) { unstable_noStore(); try { // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인 const existingQuotation = await db .select() .from(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, input.vendorId) ) ) .limit(1); if (existingQuotation.length > 0) { return { data: null, error: "이미 해당 벤더가 이 RFQ에 추가되어 있습니다." }; } // 새 벤더 견적서 레코드 생성 const [newQuotation] = await db .insert(techSalesVendorQuotations) .values({ rfqId: input.rfqId, vendorId: input.vendorId, status: "Draft", totalPrice: "0", currency: "USD", createdBy: input.createdBy, updatedBy: input.createdBy, }) .returning(); return { data: newQuotation, error: null }; } catch (err) { console.error("Error adding vendor to RFQ:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ에 여러 벤더 추가 (다중) */ export async function addVendorsToTechSalesRfq(input: { rfqId: number; vendorIds: number[]; createdBy: number; }) { unstable_noStore(); try { const results: typeof techSalesVendorQuotations.$inferSelect[] = []; const errors: string[] = []; // 트랜잭션으로 처리 await db.transaction(async (tx) => { // 1. RFQ 상태 확인 const rfq = await tx.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, input.rfqId), columns: { id: true, status: true } }); if (!rfq) { throw new Error("RFQ를 찾을 수 없습니다"); } // 2. 각 벤더에 대해 처리 for (const vendorId of input.vendorIds) { try { // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인 const existingQuotation = await tx .select() .from(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, vendorId) ) ) .limit(1); if (existingQuotation.length > 0) { errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`); continue; } // 새 벤더 견적서 레코드 생성 const [newQuotation] = await tx .insert(techSalesVendorQuotations) .values({ rfqId: input.rfqId, vendorId: vendorId, status: "Draft", totalPrice: "0", currency: "USD", createdBy: input.createdBy, updatedBy: input.createdBy, }) .returning(); results.push(newQuotation); } catch (vendorError) { console.error(`Error adding vendor ${vendorId}:`, vendorError); errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`); } } // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트 if (rfq.status === "RFQ Created" && results.length > 0) { await tx.update(techSalesRfqs) .set({ status: "RFQ Vendor Assignned", updatedBy: input.createdBy, updatedAt: new Date() }) .where(eq(techSalesRfqs.id, input.rfqId)); } }); // 캐시 무효화 추가 revalidateTag("techSalesVendorQuotations"); revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: results, error: errors.length > 0 ? errors.join(", ") : null, successCount: results.length, errorCount: errors.length }; } catch (err) { console.error("Error adding vendors to RFQ:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함) */ export async function removeVendorFromTechSalesRfq(input: { rfqId: number; vendorId: number; }) { unstable_noStore(); try { // 먼저 해당 벤더의 견적서 상태 확인 const existingQuotation = await db .select() .from(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, input.vendorId) ) ) .limit(1); if (existingQuotation.length === 0) { return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." }; } // Draft 상태가 아닌 경우 삭제 불가 if (existingQuotation[0].status !== "Draft") { return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." }; } // 해당 벤더의 견적서 삭제 const deletedQuotations = await db .delete(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, input.vendorId) ) ) .returning(); // 캐시 무효화 추가 revalidateTag("techSalesVendorQuotations"); revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: deletedQuotations[0], error: null }; } catch (err) { console.error("Error removing vendor from RFQ:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함) */ export async function removeVendorsFromTechSalesRfq(input: { rfqId: number; vendorIds: number[]; }) { unstable_noStore(); try { const results: typeof techSalesVendorQuotations.$inferSelect[] = []; const errors: string[] = []; // 트랜잭션으로 처리 await db.transaction(async (tx) => { for (const vendorId of input.vendorIds) { try { // 먼저 해당 벤더의 견적서 상태 확인 const existingQuotation = await tx .select() .from(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, vendorId) ) ) .limit(1); if (existingQuotation.length === 0) { errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`); continue; } // Draft 상태가 아닌 경우 삭제 불가 if (existingQuotation[0].status !== "Draft") { errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); continue; } // 해당 벤더의 견적서 삭제 const deletedQuotations = await tx .delete(techSalesVendorQuotations) .where( and( eq(techSalesVendorQuotations.rfqId, input.rfqId), eq(techSalesVendorQuotations.vendorId, vendorId) ) ) .returning(); if (deletedQuotations.length > 0) { results.push(deletedQuotations[0]); } } catch (vendorError) { console.error(`Error removing vendor ${vendorId}:`, vendorError); errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`); } } }); // 캐시 무효화 추가 revalidateTag("techSalesVendorQuotations"); revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: results, error: errors.length > 0 ? errors.join(", ") : null, successCount: results.length, errorCount: errors.length }; } catch (err) { console.error("Error removing vendors from RFQ:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 특정 RFQ의 벤더 목록 조회 */ export async function getTechSalesRfqVendors(rfqId: number) { unstable_noStore(); try { // Repository 함수를 사용하여 벤더 견적 목록 조회 const result = await getTechSalesVendorQuotationsWithJoin({ rfqId, page: 1, perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회 }); return { data: result.data, error: null }; } catch (err) { console.error("Error fetching RFQ vendors:", err); return { data: [], error: getErrorMessage(err) }; } } /** * 기술영업 RFQ 발송 (선택된 벤더들에게) */ export async function sendTechSalesRfqToVendors(input: { rfqId: number; vendorIds: number[]; }) { unstable_noStore(); try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, message: "인증이 필요합니다", }; } // RFQ 정보 조회 const rfq = await db.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, input.rfqId), columns: { id: true, rfqCode: true, status: true, dueDate: true, rfqSendDate: true, remark: true, materialCode: true, projectSnapshot: true, seriesSnapshot: true, }, with: { item: { columns: { id: true, itemCode: true, itemName: true, } }, biddingProject: { columns: { id: true, pspid: true, projNm: true, sector: true, ptypeNm: true, } }, createdByUser: { columns: { id: true, name: true, email: true, } } } }); if (!rfq) { return { success: false, message: "RFQ를 찾을 수 없습니다", }; } // 발송 가능한 상태인지 확인 if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { return { success: false, message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", }; } const isResend = rfq.status === "RFQ Sent"; // 현재 사용자 정보 조회 const sender = await db.query.users.findFirst({ where: eq(users.id, Number(session.user.id)), columns: { id: true, email: true, name: true, } }); if (!sender || !sender.email) { return { success: false, message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", }; } // 선택된 벤더들의 견적서 정보 조회 const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({ where: and( eq(techSalesVendorQuotations.rfqId, input.rfqId), sql`${techSalesVendorQuotations.vendorId} IN (${input.vendorIds.join(',')})` ), columns: { id: true, vendorId: true, status: true, currency: true, }, with: { vendor: { columns: { id: true, vendorName: true, vendorCode: true, } } } }); if (vendorQuotations.length === 0) { return { success: false, message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다", }; } // 트랜잭션 시작 await db.transaction(async (tx) => { // 1. RFQ 상태 업데이트 (첫 발송인 경우에만) if (!isResend) { await tx.update(techSalesRfqs) .set({ status: "RFQ Sent", rfqSendDate: new Date(), sentBy: Number(session.user.id), updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(techSalesRfqs.id, input.rfqId)); } // 2. 각 벤더에 대해 이메일 발송 처리 for (const quotation of vendorQuotations) { if (!quotation.vendorId || !quotation.vendor) continue; // 벤더에 속한 모든 사용자 조회 const vendorUsers = await db.query.users.findMany({ where: eq(users.companyId, quotation.vendorId), columns: { id: true, email: true, name: true, language: true } }); // 유효한 이메일 주소만 필터링 const vendorEmailsString = vendorUsers .filter(user => user.email) .map(user => user.email) .join(", "); if (vendorEmailsString) { // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) const language = vendorUsers[0]?.language || "ko"; // 시리즈 정보 처리 const seriesInfo = rfq.seriesSnapshot ? rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ sersNo: series.sersNo, klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, dlDt: series.dlDt, dockNo: series.dockNo, dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, })) : []; // 이메일 컨텍스트 구성 const emailContext = { language: language, rfq: { id: rfq.id, code: rfq.rfqCode, title: rfq.item?.itemName || '', projectCode: rfq.biddingProject?.pspid || '', projectName: rfq.biddingProject?.projNm || '', description: rfq.remark || '', dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A', materialCode: rfq.materialCode || '', }, vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode || '', name: quotation.vendor.vendorName, }, sender: { fullName: sender.name || '', email: sender.email, }, project: { // 기본 정보 id: rfq.projectSnapshot?.pspid || rfq.biddingProject?.pspid || '', name: rfq.projectSnapshot?.projNm || rfq.biddingProject?.projNm || '', sector: rfq.projectSnapshot?.sector || rfq.biddingProject?.sector || '', shipType: rfq.projectSnapshot?.ptypeNm || rfq.biddingProject?.ptypeNm || '', // 추가 프로젝트 정보 shipCount: rfq.projectSnapshot?.projMsrm || 0, ownerCode: rfq.projectSnapshot?.kunnr || '', ownerName: rfq.projectSnapshot?.kunnrNm || '', classCode: rfq.projectSnapshot?.cls1 || '', className: rfq.projectSnapshot?.cls1Nm || '', shipTypeCode: rfq.projectSnapshot?.ptype || '', shipModelCode: rfq.projectSnapshot?.pmodelCd || '', shipModelName: rfq.projectSnapshot?.pmodelNm || '', shipModelSize: rfq.projectSnapshot?.pmodelSz || '', shipModelUnit: rfq.projectSnapshot?.pmodelUom || '', estimateStatus: rfq.projectSnapshot?.txt30 || '', projectManager: rfq.projectSnapshot?.estmPm || '', }, series: seriesInfo, details: { currency: quotation.currency || 'USD', }, quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', isResend: isResend, versionInfo: isResend ? '(재전송)' : '', }; // 이메일 전송 await sendEmail({ to: vendorEmailsString, subject: isResend ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}` : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`, template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿 context: emailContext, cc: sender.email, // 발신자를 CC에 추가 }); } } }); // 캐시 무효화 revalidateTag("techSalesRfqs"); revalidateTag("techSalesVendorQuotations"); revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { success: true, message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`, sentCount: vendorQuotations.length, }; } catch (err) { console.error("기술영업 RFQ 발송 오류:", err); return { success: false, message: "RFQ 발송 중 오류가 발생했습니다", }; } } /** * 벤더용 기술영업 RFQ 견적서 조회 */ export async function getTechSalesVendorQuotation(quotationId: number) { unstable_noStore(); try { const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { item: true, biddingProject: true, createdByUser: { columns: { id: true, name: true, email: true, } } } }, vendor: true, } }); if (!quotation) { return { data: null, error: "견적서를 찾을 수 없습니다." }; } return { data: quotation, error: null }; } catch (err) { console.error("Error fetching vendor quotation:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 벤더 견적서 업데이트 (임시저장) */ export async function updateTechSalesVendorQuotation(data: { id: number currency: string totalPrice: string validUntil: Date remark?: string updatedBy: number }) { try { // 현재 견적서 상태 확인 const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, data.id), columns: { status: true, } }); if (!currentQuotation) { return { data: null, error: "견적서를 찾을 수 없습니다." }; } // Draft 또는 Revised 상태에서만 수정 가능 if (!["Draft", "Revised"].includes(currentQuotation.status)) { return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." }; } const result = await db .update(techSalesVendorQuotations) .set({ currency: data.currency, totalPrice: data.totalPrice, validUntil: data.validUntil, remark: data.remark || null, updatedAt: new Date(), }) .where(eq(techSalesVendorQuotations.id, data.id)) .returning() // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) return { data: result[0], error: null } } catch (error) { console.error("Error updating tech sales vendor quotation:", error) return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" } } } /** * 기술영업 벤더 견적서 제출 */ export async function submitTechSalesVendorQuotation(data: { id: number currency: string totalPrice: string validUntil: Date remark?: string updatedBy: number }) { try { // 현재 견적서 상태 확인 const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, data.id), columns: { status: true, } }); if (!currentQuotation) { return { data: null, error: "견적서를 찾을 수 없습니다." }; } // Draft 또는 Revised 상태에서만 제출 가능 if (!["Draft", "Revised"].includes(currentQuotation.status)) { return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." }; } const result = await db .update(techSalesVendorQuotations) .set({ currency: data.currency, totalPrice: data.totalPrice, validUntil: data.validUntil, remark: data.remark || null, status: "Submitted", submittedAt: new Date(), updatedAt: new Date(), }) .where(eq(techSalesVendorQuotations.id, data.id)) .returning() // 메일 발송 (백그라운드에서 실행) if (result[0]) { // 벤더에게 견적 제출 확인 메일 발송 sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { console.error("벤더 견적 제출 확인 메일 발송 실패:", error); }); // 담당자에게 견적 접수 알림 메일 발송 sendQuotationSubmittedNotificationToManager(data.id).catch(error => { console.error("담당자 견적 접수 알림 메일 발송 실패:", error); }); } // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) return { data: result[0], error: null } } catch (error) { console.error("Error submitting tech sales vendor quotation:", error) return { data: null, error: "견적서 제출 중 오류가 발생했습니다" } } } /** * 통화 목록 조회 */ export async function fetchCurrencies() { try { // 기본 통화 목록 (실제로는 DB에서 가져와야 함) const currencies = [ { code: "USD", name: "미국 달러" }, { code: "KRW", name: "한국 원" }, { code: "EUR", name: "유로" }, { code: "JPY", name: "일본 엔" }, { code: "CNY", name: "중국 위안" }, ] return { data: currencies, error: null } } catch (error) { console.error("Error fetching currencies:", error) return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" } } } /** * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함) */ export async function getVendorQuotations(input: { flags?: string[]; page: number; perPage: number; sort?: { id: string; desc: boolean }[]; filters?: Filter[]; joinOperator?: "and" | "or"; basicFilters?: Filter[]; basicJoinOperator?: "and" | "or"; search?: string; from?: string; to?: string; }, vendorId: string) { unstable_noStore(); try { const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; const offset = (page - 1) * perPage; const limit = perPage; // 기본 조건: 해당 벤더의 견적서만 조회 const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; // 검색 조건 추가 if (search) { const s = `%${search}%`; const searchCondition = or( ilike(techSalesVendorQuotations.currency, s), ilike(techSalesVendorQuotations.status, s) ); if (searchCondition) { baseConditions.push(searchCondition); } } // 날짜 범위 필터 if (from) { baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`); } if (to) { baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`); } // 고급 필터 처리 if (filters.length > 0) { const filterWhere = filterColumns({ table: techSalesVendorQuotations, filters: filters as Filter[], joinOperator: input.joinOperator || "and", }); if (filterWhere) { baseConditions.push(filterWhere); } } // 최종 WHERE 조건 const finalWhere = baseConditions.length > 0 ? and(...baseConditions) : undefined; // 정렬 기준 설정 let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)]; if (sort?.length) { orderBy = sort.map(item => { switch (item.id) { case 'id': return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; case 'status': return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; case 'currency': return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; case 'totalPrice': return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; case 'validUntil': return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil; case 'submittedAt': return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt; case 'createdAt': return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; case 'updatedAt': return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; default: return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; } }); } // 조인을 포함한 데이터 조회 const data = await db .select({ id: techSalesVendorQuotations.id, rfqId: techSalesVendorQuotations.rfqId, vendorId: techSalesVendorQuotations.vendorId, status: techSalesVendorQuotations.status, currency: techSalesVendorQuotations.currency, totalPrice: techSalesVendorQuotations.totalPrice, validUntil: techSalesVendorQuotations.validUntil, submittedAt: techSalesVendorQuotations.submittedAt, remark: techSalesVendorQuotations.remark, createdAt: techSalesVendorQuotations.createdAt, updatedAt: techSalesVendorQuotations.updatedAt, createdBy: techSalesVendorQuotations.createdBy, updatedBy: techSalesVendorQuotations.updatedBy, // RFQ 정보 rfqCode: techSalesRfqs.rfqCode, materialCode: techSalesRfqs.materialCode, dueDate: techSalesRfqs.dueDate, rfqStatus: techSalesRfqs.status, // 아이템 정보 itemName: items.itemName, // 프로젝트 정보 (JSON에서 추출) projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, // 첨부파일 개수 attachmentCount: sql`( SELECT COUNT(*) FROM tech_sales_attachments WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} )`, }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) .where(finalWhere) .orderBy(...orderBy) .limit(limit) .offset(offset); // 총 개수 조회 const totalResult = await db .select({ count: sql`count(*)` }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) .where(finalWhere); const total = totalResult[0]?.count || 0; const pageCount = Math.ceil(total / perPage); return { data, pageCount, total }; } catch (err) { console.error("Error fetching vendor quotations:", err); return { data: [], pageCount: 0, total: 0 }; } } /** * 벤더용 기술영업 견적서 상태별 개수 조회 */ export async function getQuotationStatusCounts(vendorId: string) { unstable_noStore(); try { const result = await db .select({ status: techSalesVendorQuotations.status, count: sql`count(*)`, }) .from(techSalesVendorQuotations) .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) .groupBy(techSalesVendorQuotations.status); return { data: result, error: null }; } catch (err) { console.error("Error fetching quotation status counts:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 벤더 견적 승인 (벤더 선택) */ export async function acceptTechSalesVendorQuotation(quotationId: number) { try { const result = await db.transaction(async (tx) => { // 1. 선택된 견적 정보 조회 const selectedQuotation = await tx .select() .from(techSalesVendorQuotations) .where(eq(techSalesVendorQuotations.id, quotationId)) .limit(1) if (selectedQuotation.length === 0) { throw new Error("견적을 찾을 수 없습니다") } const quotation = selectedQuotation[0] // 2. 선택된 견적을 Accepted로 변경 await tx .update(techSalesVendorQuotations) .set({ status: "Accepted", acceptedAt: new Date(), updatedAt: new Date(), }) .where(eq(techSalesVendorQuotations.id, quotationId)) // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 await tx .update(techSalesVendorQuotations) .set({ status: "Rejected", rejectionReason: "다른 벤더가 선택됨", updatedAt: new Date(), }) .where( and( eq(techSalesVendorQuotations.rfqId, quotation.rfqId), ne(techSalesVendorQuotations.id, quotationId), eq(techSalesVendorQuotations.status, "Submitted") ) ) // 4. RFQ 상태를 Closed로 변경 await tx .update(techSalesRfqs) .set({ status: "Closed", updatedAt: new Date(), }) .where(eq(techSalesRfqs.id, quotation.rfqId)) return quotation }) // 메일 발송 (백그라운드에서 실행) // 선택된 벤더에게 견적 선택 알림 메일 발송 sendQuotationAcceptedNotification(quotationId).catch(error => { console.error("벤더 견적 선택 알림 메일 발송 실패:", error); }); // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 setTimeout(async () => { try { const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ where: and( eq(techSalesVendorQuotations.rfqId, result.rfqId), ne(techSalesVendorQuotations.id, quotationId), eq(techSalesVendorQuotations.status, "Rejected") ), columns: { id: true } }); for (const rejectedQuotation of rejectedQuotations) { sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { console.error("벤더 견적 거절 알림 메일 발송 실패:", error); }); } } catch (error) { console.error("거절된 견적 알림 메일 발송 중 오류:", error); } }, 1000); // 1초 후 실행 // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidateTag(`techSalesRfq-${result.rfqId}`) revalidateTag("techSalesRfqs") return { success: true, data: result } } catch (error) { console.error("벤더 견적 승인 오류:", error) return { success: false, error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다" } } } /** * 기술영업 벤더 견적 거절 */ export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { try { const result = await db .update(techSalesVendorQuotations) .set({ status: "Rejected", rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", updatedAt: new Date(), }) .where(eq(techSalesVendorQuotations.id, quotationId)) .returning() if (result.length === 0) { throw new Error("견적을 찾을 수 없습니다") } // 메일 발송 (백그라운드에서 실행) sendQuotationRejectedNotification(quotationId).catch(error => { console.error("벤더 견적 거절 알림 메일 발송 실패:", error); }); // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidateTag(`techSalesRfq-${result[0].rfqId}`) return { success: true, data: result[0] } } catch (error) { console.error("벤더 견적 거절 오류:", error) return { success: false, error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" } } } /** * 기술영업 RFQ 첨부파일 생성 (파일 업로드) */ export async function createTechSalesRfqAttachments(params: { techSalesRfqId: number files: File[] createdBy: number attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC" description?: string }) { unstable_noStore(); try { const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params; if (!files || files.length === 0) { return { data: null, error: "업로드할 파일이 없습니다." }; } // RFQ 존재 확인 const rfq = await db.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, techSalesRfqId), columns: { id: true, status: true } }); if (!rfq) { return { data: null, error: "RFQ를 찾을 수 없습니다." }; } // 편집 가능한 상태 확인 if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." }; } const results: typeof techSalesAttachments.$inferSelect[] = []; // 트랜잭션으로 처리 await db.transaction(async (tx) => { const path = await import("path"); const fs = await import("fs/promises"); const { randomUUID } = await import("crypto"); // 파일 저장 디렉토리 생성 const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); await fs.mkdir(rfqDir, { recursive: true }); for (const file of files) { const ab = await file.arrayBuffer(); const buffer = Buffer.from(ab); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); const absolutePath = path.join(process.cwd(), "public", relativePath); // 파일 저장 await fs.writeFile(absolutePath, buffer); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ techSalesRfqId, attachmentType, fileName: uniqueName, originalFileName: file.name, filePath: "/" + relativePath.replace(/\\/g, "/"), fileSize: file.size, fileType: file.type || undefined, description: description || undefined, createdBy, }).returning(); results.push(newAttachment); } }); // 캐시 무효화 revalidateTag("techSalesRfqs"); revalidateTag(`techSalesRfq-${techSalesRfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: results, error: null }; } catch (err) { console.error("기술영업 RFQ 첨부파일 생성 오류:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ 첨부파일 조회 */ export async function getTechSalesRfqAttachments(techSalesRfqId: number) { unstable_noStore(); try { const attachments = await db.query.techSalesAttachments.findMany({ where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), orderBy: [desc(techSalesAttachments.createdAt)], with: { createdByUser: { columns: { id: true, name: true, email: true, } } } }); return { data: attachments, error: null }; } catch (err) { console.error("기술영업 RFQ 첨부파일 조회 오류:", err); return { data: [], error: getErrorMessage(err) }; } } /** * 기술영업 RFQ 첨부파일 삭제 */ export async function deleteTechSalesRfqAttachment(attachmentId: number) { unstable_noStore(); try { // 첨부파일 정보 조회 const attachment = await db.query.techSalesAttachments.findFirst({ where: eq(techSalesAttachments.id, attachmentId), }); if (!attachment) { return { data: null, error: "첨부파일을 찾을 수 없습니다." }; } // RFQ 상태 확인 const rfq = await db.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists columns: { id: true, status: true } }); if (!rfq) { return { data: null, error: "RFQ를 찾을 수 없습니다." }; } // 편집 가능한 상태 확인 if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." }; } // 트랜잭션으로 처리 const result = await db.transaction(async (tx) => { // DB에서 레코드 삭제 const deletedAttachment = await tx.delete(techSalesAttachments) .where(eq(techSalesAttachments.id, attachmentId)) .returning(); // 파일 시스템에서 파일 삭제 try { const path = await import("path"); const fs = await import("fs/promises"); const absolutePath = path.join(process.cwd(), "public", attachment.filePath); await fs.unlink(absolutePath); } catch (fileError) { console.warn("파일 삭제 실패:", fileError); // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행 } return deletedAttachment[0]; }); // 캐시 무효화 revalidateTag("techSalesRfqs"); revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: result, error: null }; } catch (err) { console.error("기술영업 RFQ 첨부파일 삭제 오류:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제) */ export async function processTechSalesRfqAttachments(params: { techSalesRfqId: number newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[] deleteAttachmentIds: number[] createdBy: number }) { unstable_noStore(); try { const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params; // RFQ 존재 및 상태 확인 const rfq = await db.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, techSalesRfqId), columns: { id: true, status: true } }); if (!rfq) { return { data: null, error: "RFQ를 찾을 수 없습니다." }; } if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." }; } const results = { uploaded: [] as typeof techSalesAttachments.$inferSelect[], deleted: [] as typeof techSalesAttachments.$inferSelect[], }; await db.transaction(async (tx) => { const path = await import("path"); const fs = await import("fs/promises"); const { randomUUID } = await import("crypto"); // 1. 삭제할 첨부파일 처리 if (deleteAttachmentIds.length > 0) { const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({ where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})` }); for (const attachment of attachmentsToDelete) { // DB에서 레코드 삭제 const [deletedAttachment] = await tx.delete(techSalesAttachments) .where(eq(techSalesAttachments.id, attachment.id)) .returning(); results.deleted.push(deletedAttachment); // 파일 시스템에서 파일 삭제 try { const absolutePath = path.join(process.cwd(), "public", attachment.filePath); await fs.unlink(absolutePath); } catch (fileError) { console.warn("파일 삭제 실패:", fileError); } } } // 2. 새 파일 업로드 처리 if (newFiles.length > 0) { const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); await fs.mkdir(rfqDir, { recursive: true }); for (const { file, attachmentType, description } of newFiles) { const ab = await file.arrayBuffer(); const buffer = Buffer.from(ab); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); const absolutePath = path.join(process.cwd(), "public", relativePath); // 파일 저장 await fs.writeFile(absolutePath, buffer); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ techSalesRfqId, attachmentType, fileName: uniqueName, originalFileName: file.name, filePath: "/" + relativePath.replace(/\\/g, "/"), fileSize: file.size, fileType: file.type || undefined, description: description || undefined, createdBy, }).returning(); results.uploaded.push(newAttachment); } } }); // 캐시 무효화 revalidateTag("techSalesRfqs"); revalidateTag(`techSalesRfq-${techSalesRfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: results, error: null, message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료` }; } catch (err) { console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err); return { data: null, error: getErrorMessage(err) }; } } // ======================================== // 메일 발송 관련 함수들 // ======================================== /** * 벤더 견적 제출 확인 메일 발송 (벤더용) */ export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) { try { // 견적서 정보 조회 const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { item: true, biddingProject: true, createdByUser: { columns: { id: true, name: true, email: true, } } } }, vendor: { columns: { id: true, vendorName: true, vendorCode: true, } } } }); if (!quotation || !quotation.rfq || !quotation.vendor) { console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; } // 벤더 사용자들 조회 const vendorUsers = await db.query.users.findMany({ where: eq(users.companyId, quotation.vendor.id), columns: { id: true, email: true, name: true, language: true } }); const vendorEmails = vendorUsers .filter(user => user.email) .map(user => user.email) .join(", "); if (!vendorEmails) { console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); return { success: false, error: "벤더 이메일 주소가 없습니다" }; } // 프로젝트 정보 준비 const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ sersNo: series.sersNo, klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, dlDt: series.dlDt, dockNo: series.dockNo, dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, })) : []; // 이메일 컨텍스트 구성 const emailContext = { language: vendorUsers[0]?.language || "ko", quotation: { id: quotation.id, currency: quotation.currency, totalPrice: quotation.totalPrice, validUntil: quotation.validUntil, submittedAt: quotation.submittedAt, remark: quotation.remark, }, rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, title: quotation.rfq.item?.itemName || '', projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, }, vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode, name: quotation.vendor.vendorName, }, project: { name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', shipCount: projectInfo.projMsrm || 0, ownerName: projectInfo.kunnrNm || '', className: projectInfo.cls1Nm || '', shipModelName: projectInfo.pmodelNm || '', }, series: seriesInfo, manager: { name: quotation.rfq.createdByUser?.name || '', email: quotation.rfq.createdByUser?.email || '', }, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', companyName: 'Samsung Heavy Industries', year: new Date().getFullYear(), }; // 이메일 발송 await sendEmail({ to: vendorEmails, subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.item?.itemName || '견적 요청'}`, template: 'tech-sales-quotation-submitted-vendor-ko', context: emailContext, }); console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`); return { success: true }; } catch (error) { console.error("벤더 견적 제출 확인 메일 발송 오류:", error); return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; } } /** * 벤더 견적 접수 알림 메일 발송 (담당자용) */ export async function sendQuotationSubmittedNotificationToManager(quotationId: number) { try { // 견적서 정보 조회 const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { item: true, biddingProject: true, createdByUser: { columns: { id: true, name: true, email: true, } } } }, vendor: { columns: { id: true, vendorName: true, vendorCode: true, } } } }); if (!quotation || !quotation.rfq || !quotation.vendor) { console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; } const manager = quotation.rfq.createdByUser; if (!manager?.email) { console.warn("담당자 이메일 주소가 없습니다"); return { success: false, error: "담당자 이메일 주소가 없습니다" }; } // 프로젝트 정보 준비 const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ sersNo: series.sersNo, klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, dlDt: series.dlDt, dockNo: series.dockNo, dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, })) : []; // 이메일 컨텍스트 구성 const emailContext = { language: "ko", quotation: { id: quotation.id, currency: quotation.currency, totalPrice: quotation.totalPrice, validUntil: quotation.validUntil, submittedAt: quotation.submittedAt, remark: quotation.remark, }, rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, title: quotation.rfq.item?.itemName || '', projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, }, vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode, name: quotation.vendor.vendorName, }, project: { name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', shipCount: projectInfo.projMsrm || 0, ownerName: projectInfo.kunnrNm || '', className: projectInfo.cls1Nm || '', shipModelName: projectInfo.pmodelNm || '', }, series: seriesInfo, manager: { name: manager.name || '', email: manager.email, }, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp', companyName: 'Samsung Heavy Industries', year: new Date().getFullYear(), }; // 이메일 발송 await sendEmail({ to: manager.email, subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`, template: 'tech-sales-quotation-submitted-manager-ko', context: emailContext, }); console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`); return { success: true }; } catch (error) { console.error("담당자 견적 접수 알림 메일 발송 오류:", error); return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; } } /** * 벤더 견적 선택 알림 메일 발송 */ export async function sendQuotationAcceptedNotification(quotationId: number) { try { // 견적서 정보 조회 const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { item: true, biddingProject: true, createdByUser: { columns: { id: true, name: true, email: true, } } } }, vendor: { columns: { id: true, vendorName: true, vendorCode: true, } } } }); if (!quotation || !quotation.rfq || !quotation.vendor) { console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; } // 벤더 사용자들 조회 const vendorUsers = await db.query.users.findMany({ where: eq(users.companyId, quotation.vendor.id), columns: { id: true, email: true, name: true, language: true } }); const vendorEmails = vendorUsers .filter(user => user.email) .map(user => user.email) .join(", "); if (!vendorEmails) { console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); return { success: false, error: "벤더 이메일 주소가 없습니다" }; } // 프로젝트 정보 준비 const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ sersNo: series.sersNo, klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, dlDt: series.dlDt, dockNo: series.dockNo, dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, })) : []; // 이메일 컨텍스트 구성 const emailContext = { language: vendorUsers[0]?.language || "ko", quotation: { id: quotation.id, currency: quotation.currency, totalPrice: quotation.totalPrice, validUntil: quotation.validUntil, acceptedAt: quotation.acceptedAt, remark: quotation.remark, }, rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, title: quotation.rfq.item?.itemName || '', projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, }, vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode, name: quotation.vendor.vendorName, }, project: { name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', shipCount: projectInfo.projMsrm || 0, ownerName: projectInfo.kunnrNm || '', className: projectInfo.cls1Nm || '', shipModelName: projectInfo.pmodelNm || '', }, series: seriesInfo, manager: { name: quotation.rfq.createdByUser?.name || '', email: quotation.rfq.createdByUser?.email || '', }, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', companyName: 'Samsung Heavy Industries', year: new Date().getFullYear(), }; // 이메일 발송 await sendEmail({ to: vendorEmails, subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`, template: 'tech-sales-quotation-accepted-ko', context: emailContext, }); console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`); return { success: true }; } catch (error) { console.error("벤더 견적 선택 알림 메일 발송 오류:", error); return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; } } /** * 벤더 견적 거절 알림 메일 발송 */ export async function sendQuotationRejectedNotification(quotationId: number) { try { // 견적서 정보 조회 const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { item: true, biddingProject: true, createdByUser: { columns: { id: true, name: true, email: true, } } } }, vendor: { columns: { id: true, vendorName: true, vendorCode: true, } } } }); if (!quotation || !quotation.rfq || !quotation.vendor) { console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; } // 벤더 사용자들 조회 const vendorUsers = await db.query.users.findMany({ where: eq(users.companyId, quotation.vendor.id), columns: { id: true, email: true, name: true, language: true } }); const vendorEmails = vendorUsers .filter(user => user.email) .map(user => user.email) .join(", "); if (!vendorEmails) { console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); return { success: false, error: "벤더 이메일 주소가 없습니다" }; } // 프로젝트 정보 준비 const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ sersNo: series.sersNo, klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, dlDt: series.dlDt, dockNo: series.dockNo, dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, })) : []; // 이메일 컨텍스트 구성 const emailContext = { language: vendorUsers[0]?.language || "ko", quotation: { id: quotation.id, currency: quotation.currency, totalPrice: quotation.totalPrice, validUntil: quotation.validUntil, rejectionReason: quotation.rejectionReason, remark: quotation.remark, }, rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, title: quotation.rfq.item?.itemName || '', projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, }, vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode, name: quotation.vendor.vendorName, }, project: { name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', shipCount: projectInfo.projMsrm || 0, ownerName: projectInfo.kunnrNm || '', className: projectInfo.cls1Nm || '', shipModelName: projectInfo.pmodelNm || '', }, series: seriesInfo, manager: { name: quotation.rfq.createdByUser?.name || '', email: quotation.rfq.createdByUser?.email || '', }, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', companyName: 'Samsung Heavy Industries', year: new Date().getFullYear(), }; // 이메일 발송 await sendEmail({ to: vendorEmails, subject: `[견적 검토 결과] ${quotation.rfq.rfqCode} - 견적 검토 결과를 안내드립니다`, template: 'tech-sales-quotation-rejected-ko', context: emailContext, }); console.log(`벤더 견적 거절 알림 메일 발송 완료: ${vendorEmails}`); return { success: true }; } catch (error) { console.error("벤더 견적 거절 알림 메일 발송 오류:", error); return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; } } // ==================== Vendor Communication 관련 ==================== export interface TechSalesAttachment { id: number fileName: string fileSize: number fileType: string | null // <- null 허용 filePath: string uploadedAt: Date } export interface TechSalesComment { id: number rfqId: number vendorId: number | null // null 허용으로 변경 userId?: number | null // null 허용으로 변경 content: string isVendorComment: boolean | null // null 허용으로 변경 createdAt: Date updatedAt: Date userName?: string | null // null 허용으로 변경 vendorName?: string | null // null 허용으로 변경 attachments: TechSalesAttachment[] isRead: boolean | null // null 허용으로 변경 } /** * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 * * @param rfqId RFQ ID * @param vendorId 벤더 ID * @returns 코멘트 목록 */ export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise { if (!vendorId) { return [] } try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다") } // 코멘트 쿼리 const comments = await db.query.techSalesRfqComments.findMany({ where: and( eq(techSalesRfqComments.rfqId, rfqId), eq(techSalesRfqComments.vendorId, vendorId) ), orderBy: [techSalesRfqComments.createdAt], with: { user: { columns: { name: true } }, vendor: { columns: { vendorName: true } }, attachments: true, } }) // 결과 매핑 return comments.map(comment => ({ id: comment.id, rfqId: comment.rfqId, vendorId: comment.vendorId, userId: comment.userId || undefined, content: comment.content, isVendorComment: comment.isVendorComment, createdAt: comment.createdAt, updatedAt: comment.updatedAt, userName: comment.user?.name, vendorName: comment.vendor?.vendorName, isRead: comment.isRead, attachments: comment.attachments.map(att => ({ id: att.id, fileName: att.fileName, fileSize: att.fileSize, fileType: att.fileType, filePath: att.filePath, uploadedAt: att.uploadedAt })) })) } catch (error) { console.error('techSales 벤더 코멘트 가져오기 오류:', error) throw error } } /** * 코멘트를 읽음 상태로 표시하는 서버 액션 * * @param rfqId RFQ ID * @param vendorId 벤더 ID */ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise { if (!vendorId) { return } try { // 인증 확인 const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다") } // 벤더가 작성한 읽지 않은 코멘트 업데이트 await db.update(techSalesRfqComments) .set({ isRead: true }) .where( and( eq(techSalesRfqComments.rfqId, rfqId), eq(techSalesRfqComments.vendorId, vendorId), eq(techSalesRfqComments.isVendorComment, true), eq(techSalesRfqComments.isRead, false) ) ) // 캐시 무효화 revalidateTag(`tech-sales-rfq-${rfqId}-comments`) } catch (error) { console.error('techSales 메시지 읽음 표시 오류:', error) throw error } }