diff options
Diffstat (limited to 'lib/techsales-rfq/service.ts')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 1540 |
1 files changed, 1540 insertions, 0 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts new file mode 100644 index 00000000..88fef4b7 --- /dev/null +++ b/lib/techsales-rfq/service.ts @@ -0,0 +1,1540 @@ +'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<string[]> { + 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<typeof techSalesVendorQuotations>[]; + 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<typeof techSalesVendorQuotations>[], + 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<typeof techSalesRfqs>[]; + 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<typeof techSalesRfqs>[], + 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<typeof techSalesVendorQuotations>[]; + joinOperator?: "and" | "or"; + basicFilters?: Filter<typeof techSalesVendorQuotations>[]; + 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<typeof techSalesVendorQuotations>[], + 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<string>`${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<number>`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<number>`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 : "벤더 견적 거절에 실패했습니다" + } + } +}
\ No newline at end of file |
