'use server' import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; import db from "@/db/db"; import { techSalesRfqs, techSalesVendorQuotations, items, users, TECH_SALES_QUOTATION_STATUSES } 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() // 캐시 무효화 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'`, }) .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 }) // 캐시 무효화 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("견적을 찾을 수 없습니다") } // 캐시 무효화 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 : "벤더 견적 거절에 실패했습니다" } } }