diff options
Diffstat (limited to 'lib/rfqs/service.ts')
| -rw-r--r-- | lib/rfqs/service.ts | 3951 |
1 files changed, 0 insertions, 3951 deletions
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts deleted file mode 100644 index 651c8eda..00000000 --- a/lib/rfqs/service.ts +++ /dev/null @@ -1,3951 +0,0 @@ -// src/lib/tasks/service.ts -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) - -import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; - -import { filterColumns } from "@/lib/filter-columns"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { getErrorMessage } from "@/lib/handle-error"; - -import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; -import path from "path"; -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' - -import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; -import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; -import logger from '@/lib/logger'; -import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; -import { sendEmail } from "../mail/sendEmail"; -import { biddingProjects, projects } from "@/db/schema/projects"; -import { items } from "@/db/schema/items"; -import * as z from "zod" -import { users } from "@/db/schema/users"; -import { headers } from 'next/headers'; - -// DRM 복호화 관련 유틸 import -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -import { deleteFile, saveDRMFile, saveFile } from "../file-stroage"; - -interface InviteVendorsInput { - rfqId: number - vendorIds: number[] - rfqType: RfqType -} - -/* ----------------------------------------------------- - 1) 조회 관련 ------------------------------------------------------ */ - -/** - * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고, - * 총 개수에 따라 pageCount를 계산해서 리턴. - * Next.js의 unstable_cache를 사용해 일정 시간 캐시. - */ -export async function getRfqs(input: GetRfqsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - // const advancedTable = input.flags.includes("advancedTable"); - const advancedTable = true; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: rfqsView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) - , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) - ) - // 필요시 여러 칼럼 OR조건 (status, priority, etc) - } - - let rfqTypeWhere; - if (input.rfqType) { - rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType); - } - - let whereConditions = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (rfqTypeWhere) whereConditions.push(rfqTypeWhere); - - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) - ) - : [asc(rfqsView.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectRfqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countRfqs(tx, finalWhere); - return { data, total }; - }); - - - const pageCount = Math.ceil(total / input.perPage); - - - return { data, pageCount }; - } catch (err) { - console.error("getRfqs 에러:", err); // 자세한 에러 로깅 - - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`rfqs-${input.rfqType}`], - } - )(); -} - -/** Status별 개수 */ -export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) { - return unstable_cache( - async () => { - try { - const initial: Record<Rfq["status"], number> = { - DRAFT: 0, - PUBLISHED: 0, - EVALUATION: 0, - AWARDED: 0, - }; - - const result = await db.transaction(async (tx) => { - // rfqType을 기준으로 필터링 추가 - const rows = await groupByStatus(tx, rfqType); - return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); - - return result; - } catch (err) { - return {} as Record<Rfq["status"], number>; - } - }, - [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가 - { - revalidate: 3600, - } - )(); -} - - - -/* ----------------------------------------------------- - 2) 생성(Create) ------------------------------------------------------ */ - -/** - * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로 - * 전체 Rfq 개수를 고정 - */ -export async function createRfq(input: CreateRfqSchema) { - - console.log(input.createdBy, "input.createdBy") - - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - try { - await db.transaction(async (tx) => { - // 새 Rfq 생성 - const [newTask] = await insertRfq(tx, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - bidProjectId: input.bidProjectId || null, - description: input.description || null, - dueDate: input.dueDate, - status: input.status, - rfqType: input.rfqType, // rfqType 추가 - createdBy: input.createdBy, - }); - return newTask; - }); - - // 캐시 무효화 - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-status-counts-${input.rfqType}`); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 3) 업데이트 ------------------------------------------------------ */ - -/** 단건 업데이트 */ -export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfq(tx, input.id, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - dueDate: input.dueDate, - rfqType: input.rfqType, - status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - createdBy: input.createdBy, - }); - return res; - }); - - revalidateTag("rfqs"); - if (data.status === input.status) { - revalidateTag("rfqs-status-counts"); - } - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function modifyRfqs(input: { - ids: number[]; - status?: Rfq["status"]; - dueDate?: Date -}) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfqs(tx, input.ids, { - status: input.status, - dueDate: input.dueDate, - }); - return res; - }); - - revalidateTag("rfqs"); - if (data.status === input.status) { - revalidateTag("rfq-status-counts"); - } - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - - -/* ----------------------------------------------------- - 4) 삭제 ------------------------------------------------------ */ - -/** 단건 삭제 */ -export async function removeRfq(input: { id: number }) { - unstable_noStore(); - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqById(tx, input.id); - // 바로 새 Rfq 생성 - }); - - revalidateTag("rfqs"); - revalidateTag("rfq-status-counts"); - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/** 복수 삭제 */ -export async function removeRfqs(input: { ids: number[] }) { - unstable_noStore(); - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqsByIds(tx, input.ids); - }); - - revalidateTag("rfqs"); - revalidateTag("rfq-status-counts"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -// 삭제를 위한 입력 스키마 -const deleteRfqItemSchema = z.object({ - id: z.number().int(), - rfqId: z.number().int(), - rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE), -}); - -type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>; - -/** - * RFQ 아이템 삭제 함수 - */ -export async function deleteRfqItem(input: DeleteRfqItemSchema) { - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - - try { - // 삭제 작업 수행 - await db - .delete(rfqItems) - .where( - and( - eq(rfqItems.id, input.id), - eq(rfqItems.rfqId, input.rfqId) - ) - ); - - console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`); - - // 캐시 무효화 - revalidateTag("rfq-items"); - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-${input.rfqId}`); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in deleteRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -// createRfqItem 함수 수정 (id 파라미터 추가) -export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { - unstable_noStore(); - - try { - // DB 트랜잭션 - await db.transaction(async (tx) => { - // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행 - if (input.id) { - // 기존 아이템 업데이트 - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, input.id)); - - console.log(`Updated RFQ item with id: ${input.id}`); - } else { - // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 - const existingItems = await tx - .select() - .from(rfqItems) - .where( - and( - eq(rfqItems.rfqId, input.rfqId), - eq(rfqItems.itemCode, input.itemCode) - ) - ); - - if (existingItems.length > 0) { - // 이미 존재하는 경우 업데이트 - const existingItem = existingItems[0]; - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, existingItem.id)); - - console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } else { - // 존재하지 않는 경우 새로 생성 - const [newItem] = await insertRfqItem(tx, { - rfqId: input.rfqId, - itemCode: input.itemCode, - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - }); - - console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } - } - }); - - // 캐시 무효화 - revalidateTag("rfq-items"); - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-${input.rfqId}`); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in createRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} -/** - * 서버 액션: 파일 첨부/삭제 처리 - * @param rfqId RFQ ID - * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열 - * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서 - * @param vendorId (optional) 업로더가 vendor인지 구분 - */ -export async function processRfqAttachments(args: { - rfqId: number; - removedExistingIds?: number[]; - newFiles?: File[]; - vendorId?: number | null; - rfqType?: RfqType | null; -}) { - const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args; - - try { - // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거 - if (removedExistingIds.length > 0) { - // 1-1) DB에서 filePath 조회 - const rows = await db - .select({ - id: rfqAttachments.id, - filePath: rfqAttachments.filePath - }) - .from(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-2) DB 삭제 - await db - .delete(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-3) 파일 삭제 - for (const row of rows) { - await deleteFile(row.filePath!); - } - } - - // 2) 새 파일 업로드 - if (newFiles.length > 0) { - for (const file of newFiles) { - - const saveResult = await saveDRMFile(file, decryptWithServerAction,'rfq' ) - - // 2-4) DB Insert - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: file.name, - filePath: saveResult.publicPath!, - // (Windows 경로 대비) - }); - } - } - - const [countRow] = await db - .select({ cnt: sql<number>`count(*)`.as("cnt") }) - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)); - - const newCount = countRow?.cnt ?? 0; - - // 3) revalidateTag 등 캐시 무효화 - revalidateTag("rfq-attachments"); - revalidateTag(`rfqs-${args.rfqType}`) - - return { ok: true, updatedItemCount: newCount }; - } catch (error) { - console.error("processRfqAttachments error:", error); - return { ok: false, error: String(error) }; - } -} - - - -export async function fetchRfqAttachments(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function fetchRfqItems(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - // id: row.id, - itemCode: row.itemCode, - description: row.description, - quantity: row.quantity, - uom: row.uom, - })) -} - -export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { - try { - logger.info({ id }, 'Fetching user by ID'); - const rfq = await getRfqById(id); - if (!rfq) { - logger.warn({ id }, 'User not found'); - } else { - logger.debug({ rfq }, 'User fetched successfully'); - } - return rfq; - } catch (error) { - logger.error({ error }, 'Error fetching user by ID'); - throw new Error('Failed to fetch user'); - } -}; - -export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) { - return unstable_cache( - async () => { - // ───────────────────────────────────────────────────── - // 1) rfq_items에서 distinct itemCode - // ───────────────────────────────────────────────────── - const itemRows = await db - .select({ code: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .groupBy(rfqItems.itemCode) - - const itemCodes = itemRows.map((r) => r.code) - const itemCount = itemCodes.length - if (itemCount === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor - // ───────────────────────────────────────────────────── - const inList = itemCodes.map((c) => `'${c}'`).join(",") - const sqlVendorIds = await db.execute( - sql` - SELECT vpi.vendor_id AS "vendorId" - FROM ${vendorPossibleItems} vpi - WHERE vpi.item_code IN (${sql.raw(inList)}) - GROUP BY vpi.vendor_id - HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} - ` - ) - const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) - if (vendorIdList.length === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 3) 필터/검색/정렬 - // ───────────────────────────────────────────────────── - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // (가) 커스텀 필터 - // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. - const advancedWhere = filterColumns({ - // 테이블이 아니라 "뷰"를 넘길 수도 있고, - // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. - table: vendorRfqView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // (나) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorRfqView.vendorName} ILIKE ${s}`, - sql`${vendorRfqView.vendorCode} ILIKE ${s}`, - sql`${vendorRfqView.email} ILIKE ${s}` - ) - } - - // (다) 최종 where - // vendorId가 vendorIdList 내에 있어야 하고, - // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. - const finalWhere = and( - inArray(vendorRfqView.vendorId, vendorIdList), - // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 - // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 - // eq(vendorRfqView.rfqId, rfqId), - advancedWhere, - globalWhere - ) - - // (라) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // "column id" -> vendorRfqView.* 중 하나 - const col = (vendorRfqView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorRfqView.vendorId)] - - // ───────────────────────────────────────────────────── - // 4) View에서 데이터 SELECT - // ───────────────────────────────────────────────────── - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - id: vendorRfqView.vendorId, - vendorID: vendorRfqView.vendorId, - vendorName: vendorRfqView.vendorName, - vendorCode: vendorRfqView.vendorCode, - address: vendorRfqView.address, - country: vendorRfqView.country, - email: vendorRfqView.email, - website: vendorRfqView.website, - vendorStatus: vendorRfqView.vendorStatus, - // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 - rfqVendorStatus: vendorRfqView.rfqVendorStatus, - rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, - }) - .from(vendorRfqView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - // 중복 제거된 데이터 생성 - const distinctData = Array.from( - new Map(data.map(row => [row.id, row])).values() - ) - - // 중복 제거된 총 개수 계산 - const [{ count }] = await tx - .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) - .from(vendorRfqView) - .where(finalWhere) - - return [distinctData, Number(count)] - }) - - - // ───────────────────────────────────────────────────── - // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 - // ───────────────────────────────────────────────────── - const distinctVendorIds = [...new Set(rows.map((r) => r.id))] - - // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 - const vendorStatuses = await db - .select({ - vendorId: vendorResponses.vendorId, - status: vendorResponses.responseStatus, - updatedAt: vendorResponses.updatedAt - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.vendorId, distinctVendorIds), - eq(vendorResponses.rfqId, rfqId) - ) - ) - - // vendorId별 상태정보 맵 생성 - const statusMap = new Map<number, { status: string, updatedAt: Date }>() - for (const vs of vendorStatuses) { - statusMap.set(vs.vendorId, { - status: vs.status, - updatedAt: vs.updatedAt - }) - } - - // 정확한 상태 정보로 업데이트된 rows 생성 - const updatedRows = rows.map(row => ({ - ...row, - rfqVendorStatus: statusMap.get(row.id)?.status || null, - rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null - })) - - // ───────────────────────────────────────────────────── - // 5) 코멘트 조회: 기존과 동일 - // ───────────────────────────────────────────────────── - console.log("distinctVendorIds", distinctVendorIds) - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId), - isNull(rfqComments.evaluationId), - isNull(rfqComments.cbeId) - ) - ) - - const commByVendorId = new Map<number, any[]>() - // 먼저 모든 사용자 ID를 수집 - const userIds = new Set(commAll.map(c => c.commentedBy)); - const userIdsArray = Array.from(userIds); - - // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴 - const usersData = await db - .select({ - id: users.id, - email: users.email, - }) - .from(users) - .where(inArray(users.id, userIdsArray)); - - // 사용자 ID를 키로 하는 맵 생성 - const userMap = new Map(); - for (const user of usersData) { - userMap.set(user.id, user); - } - - // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - - // 사용자 정보 가져오기 - const user = userMap.get(c.commentedBy); - const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정 - - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - commentedByEmail: userEmail, // 이메일 추가 - }) - } - // ───────────────────────────────────────────────────── - // 6) rows에 comments 병합 - // ───────────────────────────────────────────────────── - const final = updatedRows.map((row) => ({ - ...row, - comments: commByVendorId.get(row.id) ?? [], - })) - - // ───────────────────────────────────────────────────── - // 7) 반환 - // ───────────────────────────────────────────────────── - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { revalidate: 3600, tags: ["rfq-vendors"] } - )() -} - -export async function inviteVendors(input: InviteVendorsInput) { - unstable_noStore() // 서버 액션 캐싱 방지 - try { - const { rfqId, vendorIds } = input - if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { - throw new Error("Invalid input") - } - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 - const rfqData = await db.transaction(async (tx) => { - // 2-A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: rfqsView.rfqCode, - description: rfqsView.description, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - createdBy: rfqsView.createdBy, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // 2-B) 아이템 목록 조회 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // 2-C) 첨부파일 목록 조회 - const attachRows = await tx - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.evaluationId) - ) - ) - - const vendorRows = await tx - .select({ id: vendors.id, email: vendors.email }) - .from(vendors) - .where(inArray(vendors.id, vendorIds)) - - // NodeMailer attachments 형식 맞추기 - const attachments = [] - for (const att of attachRows) { - const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, "")) - attachments.push({ - path: absolutePath, - filename: att.fileName, - }) - } - - return { rfqRow, items, vendorRows, attachments } - }) - - const { rfqRow, items, vendorRows, attachments } = rfqData - const loginUrl = `http://${host}/en/partners/rfq` - - // 이메일 전송 오류를 기록할 배열 - const emailErrors = [] - - // 각 벤더에 대해 처리 - for (const v of vendorRows) { - if (!v.email) { - continue // 이메일 없는 협력업체 무시 - } - - try { - // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 - await db.transaction(async (tx) => { - // rfq_vendors upsert - const existing = await tx - .select() - .from(vendorResponses) - .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id))) - - if (existing.length > 0) { - await tx - .update(vendorResponses) - .set({ - responseStatus: "INVITED", - updatedAt: new Date(), - }) - .where(eq(vendorResponses.id, existing[0].id)) - } else { - await tx.insert(vendorResponses).values({ - rfqId, - vendorId: v.id, - responseStatus: "INVITED", - }) - } - }) - - // 이메일 발송 (트랜잭션 외부) - await sendEmail({ - to: v.email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: v.id, - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl - }, - attachments, - }) - } catch (err) { - // 개별 협력업체 처리 실패 로깅 - console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) - emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) - // 계속 진행 (다른 협력업체 처리) - } - } - - // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션) - try { - await db.transaction(async (tx) => { - await tx - .update(rfqs) - .set({ - status: "PUBLISHED", - updatedAt: new Date(), - }) - .where(eq(rfqs.id, rfqId)) - - console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) - }) - - // 캐시 무효화 - revalidateTag("rfq-vendors") - revalidateTag("cbe-vendors") - revalidateTag("rfqs") - revalidateTag(`rfqs-${input.rfqType}`) - revalidateTag(`rfq-${rfqId}`) - - // 이메일 오류가 있었는지 확인 - if (emailErrors.length > 0) { - return { - error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`, - emailErrors - } - } - - return { error: null } - } catch (err) { - return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` } - } - } catch (err) { - return { error: getErrorMessage(err) } - } -} - - -/** - * TBE용 평가 데이터 목록 조회 - */ -export async function getTBE(input: GetTBESchema, rfqId: number) { - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - eq(vendorTbeView.rfqId, rfqId), - // notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - - technicalResponseId:vendorTbeView.technicalResponseId, - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - technicalSummary:vendorTbeView.technicalSummary, - technicalNotes:vendorTbeView.technicalNotes, - technicalUpdated:vendorTbeView.technicalUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 - // Step 1: Get vendorResponses for the rfqId and vendorIds - const responsesAll = await db - .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by vendorId for later lookup - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); - } - - // Step 2: Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // Step 3: Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Step 4: Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // Step 5: Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Step 6: Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - // Skip if technicalResponseId is null (should never happen due to our filter above) - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // Step 7: Create the final filesByVendorId map - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByVendorId.get(vendorId)!.push(...files); - } - } - } - - // 10) 최종 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { - revalidate: 3600, - tags: ["tbe-vendors"], - } - )() -} - -export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { - - if (isNaN(vendorId) || vendorId === null || vendorId === undefined) { - throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다"); - } - - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - isNotNull(vendorTbeView.tbeId), - eq(vendorTbeView.vendorId, vendorId), - // notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - rfqType:vendorTbeView.rfqType, - rfqStatus:vendorTbeView.rfqStatus, - rfqDescription: vendorTbeView.description, - rfqDueDate: vendorTbeView.dueDate, - - - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - vendorResponseId: vendorTbeView.vendorResponseId, - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - // - evaluationId != null && evalType = "TBE" - // - => leftJoin(rfqEvaluations) or innerJoin - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] - - // (A) 조인 방식 - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, // (optional) - }) - .from(rfqComments) - // evalType = 'TBE' - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 템플릿 파일 수 조회 - const templateFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - fileCount: sql<number>`count(*)`.as("file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId) - - // tbeId -> fileCount 매핑 - null 체크 추가 - const templateFileCountMap = new Map<number, number>() - for (const tf of templateFiles) { - if (tf.tbeId !== null) { - templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) - } - } - - // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) - const tbeResponseFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - vendorId: rfqAttachments.vendorId, - responseFileCount: sql<number>`count(*)`.as("response_file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - inArray(rfqAttachments.vendorId, distinctVendorIds), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) - - // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 - const tbeResponseMap = new Map<string, number>() - for (const rf of tbeResponseFiles) { - if (rf.tbeId !== null && rf.vendorId !== null) { - const key = `${rf.tbeId}_${rf.vendorId}` - tbeResponseMap.set(key, Number(rf.responseFileCount)) - } - } - - // 11) 최종 합치기 - const final = rows.map((row) => { - const tbeId = row.tbeId - const vendorId = row.vendorId - - // 템플릿 파일 수 - const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 - - // 응답 파일 여부 - const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" - const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - templateFileCount, // 추가: 템플릿 파일 수 - hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 - } - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가 - { - revalidate: 3600, - tags: [`tbe-vendors-${vendorId}`], - } - )() -} - -export async function inviteTbeVendorsAction(formData: FormData) { - // 캐싱 방지 - unstable_noStore() - - try { - // 1) FormData에서 기본 필드 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIdsRaw = formData.getAll("vendorIds[]") - const vendorIds = vendorIdsRaw.map((id) => Number(id)) - - // 2) FormData에서 파일들 추출 (multiple) - const tbeFiles = formData.getAll("tbeFiles") as File[] - if (!rfqId || !vendorIds.length || !tbeFiles.length) { - throw new Error("Invalid input or no files attached.") - } - - // DB 트랜잭션 - await db.transaction(async (tx) => { - // (A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: vendorResponsesView.rfqCode, - description: vendorResponsesView.rfqDescription, - projectCode: vendorResponsesView.projectCode, - projectName: vendorResponsesView.projectName, - dueDate: vendorResponsesView.rfqDueDate, - createdBy: vendorResponsesView.rfqCreatedBy, - }) - .from(vendorResponsesView) - .where(eq(vendorResponsesView.rfqId, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // (B) RFQ 아이템 목록 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // (C) 대상 벤더들 (이메일 정보 확장) - const vendorRows = await tx - .select({ - id: vendors.id, - name: vendors.vendorName, - email: vendors.email, - representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 - }) - .from(vendors) - .where(sql`${vendors.id} in (${vendorIds})`) - - // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 - // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. - // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. - const savedFiles = [] - for (const file of tbeFiles) { - - const saveResult = await saveFile({file, directory:'rfb'}); - // 저장 경로 & 파일명 기록 - savedFiles.push({ - fileName: file.name, // 원본 파일명으로 첨부 - filePath: saveResult.publicPath, // public 이하 경로 - absolutePath: saveResult.publicPath, - }) - } - - // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 - for (const vendor of vendorRows) { - // 1) 협력업체 연락처 조회 - 추가 이메일 수집 - const contacts = await tx - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendor.id)) - - // 2) 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 협력업체 이메일 추가 (있는 경우에만) - if (vendor.email) { - allEmails.add(vendor.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendor.representativeEmail) { - allEmails.add(vendor.representativeEmail.trim().toLowerCase()) - } - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) - continue - } - - // 3) TBE 평가 레코드 생성 - const [evalRow] = await tx - .insert(rfqEvaluations) - .values({ - rfqId, - vendorId: vendor.id, - evalType: "TBE", - }) - .returning({ id: rfqEvaluations.id }) - - // 4) rfqAttachments에 저장한 파일들을 기록 - for (const sf of savedFiles) { - await tx.insert(rfqAttachments).values({ - rfqId, - vendorId: vendor.id, - evaluationId: evalRow.id, - fileName: sf.fileName, - filePath: sf.filePath, - }) - } - - // 5) 각 고유 이메일 주소로 초대 메일 발송 - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/ko/partners/rfq` - - console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) - - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendor.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: vendor.id, - contactName, // 연락처 이름 추가 - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl, - }, - attachments: savedFiles.map((sf) => ({ - path: sf.absolutePath, - filename: sf.fileName, - })), - }) - console.log(`이메일 전송 성공: ${email} (${contactName})`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - // 6) 캐시 무효화 - revalidateTag("tbe-vendors") - }) - - // 성공 - return { error: null } - } catch (err) { - console.error("[inviteTbeVendorsAction] Error:", err) - return { error: getErrorMessage(err) } - } -} -////partners - - -export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfqVendor(tx, input.id, { - responseStatus: input.status, - }); - return res; - }); - - revalidateTag("rfqs-vendor"); - revalidateTag("rfq-vendors"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function createRfqCommentWithAttachments(params: { - rfqId: number - vendorId?: number | null - commentText: string - commentedBy: number - evaluationId?: number | null - cbeId?: number | null - files?: File[] -}) { - const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params - console.log("cbeId", cbeId) - console.log("evaluationId", evaluationId) - // 1) 새로운 코멘트 생성 - const [insertedComment] = await db - .insert(rfqComments) - .values({ - rfqId, - vendorId: vendorId || null, - commentText, - commentedBy, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - }) - .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 - - if (!insertedComment) { - throw new Error("Failed to create comment") - } - - // 2) 첨부파일 처리 - if (files && files.length > 0) { - - for (const file of files) { - - const saveResult = await saveFile({file, directory:'rfq'}) - - // DB에 첨부파일 row 생성 - await db.insert(rfqAttachments).values({ - rfqId, - vendorId: vendorId || null, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - commentId: insertedComment.id, // 새 코멘트와 연결 - fileName: file.name, - filePath:saveResult.publicPath!, - }) - } - } - - revalidateTag("rfq-vendors"); - - return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } -} - -export async function fetchRfqAttachmentsbyCommentId(commentId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.commentId, commentId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - evaluationId: row.evaluationId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function updateRfqComment(params: { - commentId: number - commentText: string -}) { - const { commentId, commentText } = params - - // 예: 간단한 길이 체크 등 유효성 검사 - if (!commentText || commentText.trim().length === 0) { - throw new Error("Comment text must not be empty.") - } - - // DB 업데이트 - const updatedRows = await db - .update(rfqComments) - .set({ commentText }) // 필요한 컬럼만 set - .where(eq(rfqComments.id, commentId)) - .returning({ id: rfqComments.id }) - - // 혹은 returning 전체(row)를 받아서 확인할 수도 있음 - if (updatedRows.length === 0) { - // 해당 id가 없으면 예외 - throw new Error("Comment not found or already deleted.") - } - revalidateTag("rfq-vendors"); - return { ok: true } -} - -export type Project = { - id: number; - projectCode: string; - projectName: string; - type: string; -} - -export async function getProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: projects.id, - projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 - projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 - type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정 - }) - .from(projects) - .orderBy(projects.code); - - return results; - }); - - return projectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - - -export async function getBidProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: biddingProjects.id, - projectCode: biddingProjects.pspid, - projectName: biddingProjects.projNm, - }) - .from(biddingProjects) - .orderBy(biddingProjects.id); - - return results; - }); - - // Handle null projectName values - const validProjectList = projectList.map(project => ({ - ...project, - projectName: project.projectName || '' // Replace null with empty string - })); - - return validProjectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - - -// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 -export interface BudgetaryRfq { - id: number; - rfqCode: string | null; // null 허용으로 변경 - description: string | null; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -type GetBudgetaryRfqsResponse = - | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never } - | { error: string; rfqs?: never; totalCount: number } -/** - * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션 - * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함 - * 페이징 및 필터링 기능 포함 - */ -export interface GetBudgetaryRfqsParams { - search?: string; - projectId?: number; - rfqId?: number; // 특정 ID로 단일 RFQ 검색 - rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링 - limit?: number; - offset?: number; -} - -export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { - const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params; - const cacheKey = `rfqs-query-${JSON.stringify(params)}`; - - return unstable_cache( - async () => { - try { - // 기본 검색 조건 구성 - let baseCondition; - - // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우) - if (rfqTypes && rfqTypes.length > 0) { - // 여러 타입으로 필터링 (OR 조건) - baseCondition = inArray(rfqs.rfqType, rfqTypes); - } else { - // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지) - baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); - } - - // 특정 ID로 검색하는 경우 - if (rfqId) { - baseCondition = and(baseCondition, eq(rfqs.id, rfqId)); - } - - let where1; - // 검색어 조건 추가 (있을 경우) - if (search && search.trim()) { - const searchTerm = `%${search.trim()}%`; - const searchCondition = or( - ilike(rfqs.rfqCode, searchTerm), - ilike(rfqs.description, searchTerm), - ilike(projects.code, searchTerm), - ilike(projects.name, searchTerm) - ); - where1 = searchCondition; - } - - let where2; - // 프로젝트 ID 조건 추가 (있을 경우) - if (projectId) { - where2 = eq(rfqs.projectId, projectId); - } - - const finalWhere = and(baseCondition, where1, where2); - - // 총 개수 조회 - const [countResult] = await db - .select({ count: count() }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere); - - // 실제 데이터 조회 - const resultRfqs = await db - .select({ - id: rfqs.id, - rfqCode: rfqs.rfqCode, - description: rfqs.description, - rfqType: rfqs.rfqType, // RFQ 타입 필드 추가 - projectId: rfqs.projectId, - projectCode: projects.code, - projectName: projects.name, - }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere) - .orderBy(desc(rfqs.createdAt)) - .limit(limit) - .offset(offset); - - return { - rfqs: resultRfqs, - totalCount: Number(countResult?.count) || 0 - }; - } catch (error) { - console.error("Error fetching RFQs:", error); - return { - error: "Failed to fetch RFQs", - totalCount: 0 - }; - } - }, - [cacheKey], - { - revalidate: 60, // 1분 캐시 - tags: ["rfqs-query"], - } - )(); -} -export async function getAllVendors() { - // Adjust the query as needed (add WHERE, ORDER, etc.) - const allVendors = await db.select().from(vendors) - return allVendors -} - - -export async function getVendorContactsByVendorId(vendorId: number) { - try { - const contacts = await db.query.vendorContacts.findMany({ - where: eq(vendorContacts.vendorId, vendorId), - }); - - return { success: true, data: contacts }; - } catch (error) { - console.error("Error fetching vendor contacts:", error); - return { success: false, error: "Failed to fetch vendor contacts" }; - } -} -/** - * Server action to associate items from an RFQ with a vendor - * - * @param rfqId - The ID of the RFQ containing items to associate - * @param vendorId - The ID of the vendor to associate items with - * @returns Object indicating success or failure - */ -export async function addItemToVendors(rfqId: number, vendorIds: number[]) { - try { - // Input validation - if (!vendorIds.length) { - return { - success: false, - error: "No vendors selected" - }; - } - - // 1. Find all itemCodes associated with the given rfqId using select - const rfqItemResults = await db - .select({ itemCode: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)); - - // Extract itemCodes - const itemCodes = rfqItemResults.map(item => item.itemCode); - - if (itemCodes.length === 0) { - return { - success: false, - error: "No items found for this RFQ" - }; - } - - // 2. Find existing vendor-item combinations to avoid duplicates - const existingCombinations = await db - .select({ - vendorId: vendorPossibleItems.vendorId, - itemCode: vendorPossibleItems.itemCode - }) - .from(vendorPossibleItems) - .where( - and( - inArray(vendorPossibleItems.vendorId, vendorIds), - inArray(vendorPossibleItems.itemCode, itemCodes) - ) - ); - - // Create a Set of existing combinations for easy lookups - const existingSet = new Set(); - existingCombinations.forEach(combo => { - existingSet.add(`${combo.vendorId}-${combo.itemCode}`); - }); - - // 3. Prepare records to insert (only non-existing combinations) - const recordsToInsert = []; - - for (const vendorId of vendorIds) { - for (const itemCode of itemCodes) { - const key = `${vendorId}-${itemCode}`; - if (!existingSet.has(key)) { - recordsToInsert.push({ - vendorId, - itemCode, - // createdAt and updatedAt will be set by defaultNow() - }); - } - } - } - - // 4. Bulk insert if there are records to insert - let insertedCount = 0; - if (recordsToInsert.length > 0) { - const result = await db.insert(vendorPossibleItems).values(recordsToInsert); - insertedCount = recordsToInsert.length; - } - - // 5. Revalidate to refresh data - revalidateTag("rfq-vendors"); - - // 6. Return success with counts - return { - success: true, - insertedCount, - totalPossibleItems: vendorIds.length * itemCodes.length, - vendorCount: vendorIds.length, - itemCount: itemCodes.length - }; - } catch (error) { - console.error("Error adding items to vendors:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error" - }; - } -} - -/** - * 특정 평가에 대한 TBE 템플릿 파일 목록 조회 - * evaluationId가 일치하고 vendorId가 null인 파일 목록 - */ -export async function fetchTbeTemplateFiles(evaluationId: number) { - try { - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt, - }) - .from(rfqAttachments) - .where( - and( - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.vendorId), - eq(rfqAttachments.evaluationId, evaluationId), - // eq(rfqAttachments.vendorId, vendorId), - - ) - ) - - return { files, error: null } - } catch (error) { - console.error("Error fetching TBE template files:", error) - return { - files: [], - error: "템플릿 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getFileFromRfqAttachmentsbyid(fileId: number) { - try { - const file = await db - .select({ - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where(eq(rfqAttachments.id, fileId)) - .limit(1) - - if (!file.length) { - return { file: null, error: "파일을 찾을 수 없습니다." } - } - - return { file: file[0], error: null } - } catch (error) { - console.error("Error getting TBE template file info:", error) - return { - file: null, - error: "파일 정보를 가져오는 중 오류가 발생했습니다." - } - } -} - -/** - * TBE 응답 파일 업로드 처리 - */ -export async function uploadTbeResponseFile(formData: FormData) { - try { - const file = formData.get("file") as File - const rfqId = parseInt(formData.get("rfqId") as string) - const vendorId = parseInt(formData.get("vendorId") as string) - const evaluationId = parseInt(formData.get("evaluationId") as string) - const vendorResponseId = parseInt(formData.get("vendorResponseId") as string) - - if (!file || !rfqId || !vendorId || !evaluationId) { - return { - success: false, - error: "필수 필드가 누락되었습니다." - } - } - - // 타임스탬프 기반 고유 파일명 생성 - const timestamp = Date.now() - const originalName = file.name - const fileExtension = originalName.split(".").pop() - const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` - - // 업로드 디렉토리 및 경로 정의 - const uploadDir = join(process.cwd(), "rfq", "tbe-responses") - - // 디렉토리가 없으면 생성 - try { - await mkdir(uploadDir, { recursive: true }) - } catch (error) { - // 이미 존재하면 무시 - } - - const filePath = join(uploadDir, fileName) - - // 파일을 버퍼로 변환 - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) - - // 파일을 서버에 저장 - await writeFile(filePath, buffer) - - // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 - const technicalResponse = await db.insert(vendorTechnicalResponses) - .values({ - responseId: vendorResponseId, - summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 - notes: `파일명: ${originalName}`, - responseStatus:"SUBMITTED" - }) - .returning({ id: vendorTechnicalResponses.id }); - - // 생성된 기술 응답 ID 가져오기 - const technicalResponseId = technicalResponse[0].id; - - // 파일 정보를 데이터베이스에 저장 - const dbFilePath = `/rfq/tbe-responses/${fileName}` - - // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 - await db.insert(vendorResponseAttachments) - .values({ - // 오류 메시지를 기반으로 올바른 필드 이름 사용 - // 테이블 스키마에 정의된 필드만 포함해야 함 - responseId: vendorResponseId, - technicalResponseId: technicalResponseId, - // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 - // vendorId: vendorId, - // evaluationId: evaluationId, - fileName: originalName, - filePath: dbFilePath, - uploadedAt: new Date(), - }); - - // 경로 재검증 (캐시된 데이터 새로고침) - revalidatePath(`/rfq/${rfqId}/tbe`) - revalidateTag(`tbe-vendors-${vendorId}`) - - return { - success: true, - message: "파일이 성공적으로 업로드되었습니다." - } - } catch (error) { - console.error("Error uploading file:", error) - return { - success: false, - error: "파일 업로드에 실패했습니다." - } - } -} - -export async function getTbeSubmittedFiles(responseId: number) { - try { - // First, get the technical response IDs where vendorResponseId matches responseId - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ) - - if (technicalResponses.length === 0) { - return { files: [], error: null } - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id) - - // Then get attachments where technicalResponseId matches any of the IDs we found - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt) - - return { files, error: null } - } catch (error) { - return { files: [], error: 'Failed to fetch TBE submitted files' } - } -} - - - -export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { - try { - // Step 1: Get responseId from vendor_responses table - const response = await db - .select({ - id: vendorResponses.id, - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1); - - if (!response || response.length === 0) { - return { files: [], error: 'No vendor response found' }; - } - - const responseId = response[0].id; - - // Step 2: Get the technical response IDs - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ); - - if (technicalResponses.length === 0) { - return { files: [], error: null }; - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id); - - // Step 3: Get attachments where technicalResponseId matches any of the IDs - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt); - - return { files, error: null }; - } catch (error) { - return { files: [], error: 'Failed to fetch vendor files' }; - } -} - -export async function getAllTBE(input: GetTBESchema) { - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}`, - sql`${vendorTbeView.rfqCode} ILIKE ${s}`, - sql`${vendorTbeView.projectCode} ILIKE ${s}`, - sql`${vendorTbeView.projectName} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) rfqType 필터 추가 - const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined - - // 6) finalWhere - rfqType 필터 추가 - const finalWhere = and( - notRejected, - advancedWhere, - globalWhere, - rfqTypeFilter // 새로 추가된 rfqType 필터 - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - tbeResult: vendorTbeView.tbeResult, - - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Get distinct rfqIds and vendorIds - filter out nulls - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - - // 9) Comments 조회 - const commentsConditions = [isNotNull(rfqComments.evaluationId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where(and(...commentsConditions)); - - // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping - const commByCompositeKey = new Map<string, any[]>() - for (const c of commAll) { - if (!c.rfqId || !c.vendorId) continue; - - const compositeKey = `${c.rfqId}-${c.vendorId}`; - if (!commByCompositeKey.has(compositeKey)) { - commByCompositeKey.set(compositeKey, []) - } - commByCompositeKey.get(compositeKey)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 10) Responses 조회 - const responsesAll = await db - .select({ - id: vendorResponses.id, - rfqId: vendorResponses.rfqId, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.rfqId, distinctRfqIds), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by rfqId-vendorId composite key - const responsesByCompositeKey = new Map<string, number[]>(); - for (const resp of responsesAll) { - const compositeKey = `${resp.rfqId}-${resp.vendorId}`; - if (!responsesByCompositeKey.has(compositeKey)) { - responsesByCompositeKey.set(compositeKey, []); - } - responsesByCompositeKey.get(compositeKey)!.push(resp.id); - } - - // Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // 11) Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // 12) Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // 13) Create the final filesByCompositeKey map - const filesByCompositeKey = new Map<string, any[]>(); - - for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) { - filesByCompositeKey.set(compositeKey, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - } - - // 14) 최종 합치기 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByCompositeKey.get(compositeKey) ?? [], - files: filesByCompositeKey.get(compositeKey) ?? [], - }; - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: ["all-tbe-vendors"], - } - )() -} - - -export async function getCBE(input: GetCBESchema, rfqId: number) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.rfqId, rfqId), - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] 협력업체 ID 목록 추출 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ); - - // vendorId별 코멘트 그룹화 - const commentsByVendorId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const vendorId = comment.vendorId!; - if (!commentsByVendorId.has(vendorId)) { - commentsByVendorId.set(vendorId, []); - } - commentsByVendorId.get(vendorId)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByVendorId.get(row.vendorId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe-vendors-${rfqId}`], - } - )(); -} - -export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { - try { - if (!rfqType) { - return { code: "", error: 'RFQ 타입이 필요합니다' }; - } - - // 현재 연도 가져오기 - const currentYear = new Date().getFullYear(); - - // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기 - const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode }) - .from(rfqs) - .where(and( - sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`, - eq(rfqs.rfqType, rfqType) - )) - .orderBy(desc(rfqs.rfqCode)) - .limit(1); - - let sequenceNumber = 1; - - if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) { - // null 체크 추가 - TypeScript 오류 해결 - const latestCode = latestRfqs[0].rfqCode; - const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/); - - if (matches && matches[1]) { - sequenceNumber = parseInt(matches[1], 10) + 1; - } - } - - // 새로운 RFQ 코드 포맷팅 - const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' : - rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ'; - - const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`; - - return { code: newCode }; - } catch (error) { - console.error('Error generating next RFQ code:', error); - return { code: "", error: '코드 생성에 실패했습니다' }; - } -} - -interface SaveTbeResultParams { - id: number // id from the rfq_evaluations table - vendorId: number // vendorId from the rfq_evaluations table - result: string // The selected evaluation result - notes: string // The evaluation notes -} - -export async function saveTbeResult({ - id, - vendorId, - result, - notes, -}: SaveTbeResultParams) { - try { - // Check if we have all required data - if (!id || !vendorId || !result) { - return { - success: false, - message: "Missing required data for evaluation update", - } - } - - // Update the record in the database - await db - .update(rfqEvaluations) - .set({ - result: result, - notes: notes, - updatedAt: new Date(), - }) - .where( - and( - eq(rfqEvaluations.id, id), - eq(rfqEvaluations.vendorId, vendorId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - - // Revalidate the tbe-vendors tag to refresh the data - revalidateTag("tbe-vendors") - revalidateTag("all-tbe-vendors") - - return { - success: true, - message: "TBE evaluation updated successfully", - } - } catch (error) { - console.error("Failed to update TBE evaluation:", error) - - return { - success: false, - message: error instanceof Error ? error.message : "An unknown error occurred", - } - } -} - - -export async function createCbeEvaluation(formData: FormData) { - try { - // 폼 데이터 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) - const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null - - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // 기본 CBE 데이터 추출 - const rawData = { - rfqId, - paymentTerms: formData.get("paymentTerms") as string, - incoterms: formData.get("incoterms") as string, - deliverySchedule: formData.get("deliverySchedule") as string, - notes: formData.get("notes") as string, - // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) - // vendorId: vendorIds[0] || 0, - } - - // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) - const validationResult = createCbeEvaluationSchema.safeParse(rawData) - if (!validationResult.success) { - const errors = validationResult.error.format() - console.error("Validation errors:", errors) - return { error: "입력 데이터가 유효하지 않습니다." } - } - - const validData = validationResult.data - - // RFQ 정보 조회 - const [rfqInfo] = await db - .select({ - rfqCode: rfqsView.rfqCode, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - description: rfqsView.description, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqInfo) { - return { error: "RFQ 정보를 찾을 수 없습니다." } - } - - // 파일 처리 준비 - const files = formData.getAll("files") as File[] - const hasFiles = files && files.length > 0 && files[0].size > 0 - - - // 첨부 파일 정보를 저장할 배열 - const attachments: { filename: string; path: string }[] = [] - - // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 - if (hasFiles) { - for (const file of files) { - if (file.size > 0) { - const originalFilename = file.name - const fileExtension = path.extname(originalFilename) - const timestamp = new Date().getTime() - const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` - const filePath = path.join("rfq", String(rfqId), safeFilename) - const fullPath = path.join(process.cwd(), "public", filePath) - - const saveResult = await saveFile({file, directory:'rfq'}) - - } - } - } - - // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 - const createdCbeIds: number[] = [] - const failedVendors: { id: number, reason: string }[] = [] - - for (const vendorId of vendorIds) { - try { - // 협력업체 정보 조회 (이메일 포함) - const [vendorInfo] = await db - .select({ - id: vendors.id, - name: vendors.vendorName, - vendorCode: vendors.vendorCode, - email: vendors.email, // 협력업체 자체 이메일 추가 - representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - - if (!vendorInfo) { - failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) - continue - } - - // 기존 협력업체 응답 레코드 찾기 - const existingResponse = await db - .select({ id: vendorResponses.id }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1) - - if (existingResponse.length === 0) { - console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) - failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) - continue // 다음 벤더로 넘어감 - } - - // 1. CBE 평가 레코드 생성 - const [newCbeEvaluation] = await db - .insert(cbeEvaluations) - .values({ - rfqId, - vendorId, - evaluatedBy, - result: "PENDING", // 초기 상태는 PENDING으로 설정 - totalCost: 0, // 초기값은 0으로 설정 - currency: "USD", // 기본 통화 설정 - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliverySchedule: validData.deliverySchedule || null, - notes: validData.notes || null, - }) - .returning({ id: cbeEvaluations.id }) - - if (!newCbeEvaluation?.id) { - failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) - continue - } - - // 2. 상업 응답 레코드 생성 - const [newCbeResponse] = await db - .insert(vendorCommercialResponses) - .values({ - responseId: existingResponse[0].id, - responseStatus: "PENDING", - currency: "USD", - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliveryPeriod: validData.deliverySchedule || null, - }) - .returning({ id: vendorCommercialResponses.id }) - - if (!newCbeResponse?.id) { - failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) - continue - } - - createdCbeIds.push(newCbeEvaluation.id) - - // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 - if (hasFiles) { - for (let i = 0; i < attachments.length; i++) { - const attachment = attachments[i] - - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: attachment.filename, - filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 - cbeId: newCbeEvaluation.id, - }) - } - } - - // 4. 협력업체 연락처 조회 - const contacts = await db - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendorId)) - - // 5. 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 협력업체 자체 이메일 추가 (있는 경우에만) - if (vendorInfo.email) { - allEmails.add(vendorInfo.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendorInfo.representativeEmail) { - allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) - } - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) - } else { - console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) - - // 이메일 발송에 필요한 공통 데이터 준비 - const emailData = { - rfqId, - cbeId: newCbeEvaluation.id, - vendorId, - rfqCode: rfqInfo.rfqCode, - projectCode: rfqInfo.projectCode, - projectName: rfqInfo.projectName, - dueDate: rfqInfo.dueDate, - description: rfqInfo.description, - vendorName: vendorInfo.name, - vendorCode: vendorInfo.vendorCode, - paymentTerms: validData.paymentTerms, - incoterms: validData.incoterms, - deliverySchedule: validData.deliverySchedule, - notes: validData.notes, - loginUrl: `http://${host}/en/partners/cbe` - } - - // 각 고유 이메일 주소로 이메일 발송 - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendorInfo.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, - template: "cbe-invitation", - context: { - language: "ko", // 또는 다국어 처리를 위한 설정 - contactName, - ...emailData, - }, - attachments: attachments, - }) - console.log(`이메일 전송 성공: ${email}`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - } catch (err) { - console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) - failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) - } - } - - // UI 업데이트를 위한 경로 재검증 - revalidatePath(`/rfq/${rfqId}`) - revalidateTag(`cbe-vendors-${rfqId}`) - - // 결과 반환 - if (createdCbeIds.length === 0) { - return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } - } - - return { - success: true, - cbeIds: createdCbeIds, - totalCreated: createdCbeIds.length, - totalFailed: failedVendors.length, - failedVendors: failedVendors.length > 0 ? failedVendors : undefined - } - - } catch (error) { - console.error("CBE 평가 생성 중 오류 발생:", error) - return { error: "예상치 못한 오류가 발생했습니다." } - } -} - -export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) - // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 - isNotNull(vendorResponseCBEView.commercialCreatedAt), - // notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] RFQ ID 목록 추출 - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.vendorId, vendorId), - inArray(rfqComments.rfqId, distinctRfqIds) - ) - ); - - // rfqId별 코멘트 그룹화 - const commentsByRfqId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const rfqId = comment.rfqId!; - if (!commentsByRfqId.has(rfqId)) { - commentsByRfqId.set(rfqId, []); - } - commentsByRfqId.get(rfqId)!.push({ - id: comment.id, - commentText: comment.commentText, - rfqId: comment.rfqId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByRfqId.get(row.rfqId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe-vendor-${vendorId}`], - } - )(); -} - -export async function fetchCbeFiles(vendorId: number, rfqId: number) { - try { - // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. - const cbeEval = await db - .select({ id: cbeEvaluations.id }) - .from(cbeEvaluations) - .where( - and( - eq(cbeEvaluations.rfqId, rfqId), - eq(cbeEvaluations.vendorId, vendorId) - ) - ) - .limit(1) - - if (!cbeEval.length) { - return { - files: [], - error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." - } - } - - const cbeId = cbeEval[0].id - - // 2. 관련 첨부 파일을 조회합니다. - // - commentId와 evaluationId는 null이어야 함 - // - rfqId와 vendorId가 일치해야 함 - // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - eq(rfqAttachments.vendorId, vendorId), - eq(rfqAttachments.cbeId, cbeId), - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.evaluationId) - ) - ) - .orderBy(rfqAttachments.createdAt) - - return { - files, - cbeId - } - } catch (error) { - console.error("CBE 파일 조회 중 오류 발생:", error) - return { - files: [], - error: "CBE 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getAllCBE(input: GetCBESchema) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] rfqType 필터 추가 - const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined; - - // [6] 최종 where 조건 - const finalWhere = and( - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined, - rfqTypeFilter // 새로 추가된 rfqType 필터 - ); - - // [7] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 - - // [8] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [10] CBE 평가 관련 코멘트 조회 - const commentsConditions = [isNotNull(rfqComments.cbeId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where(and(...commentsConditions)); - - // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 - const commentsByCompositeKey = new Map<string, any[]>(); - for (const comment of commentsAll) { - if (!comment.rfqId || !comment.vendorId) continue; - - const compositeKey = `${comment.rfqId}-${comment.vendorId}`; - if (!commentsByCompositeKey.has(compositeKey)) { - commentsByCompositeKey.set(compositeKey, []); - } - commentsByCompositeKey.get(compositeKey)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [12] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [13] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [14] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 - const filesByCompositeKey = new Map<string, any[]>(); - - // responseId -> rfqId-vendorId 매핑 생성 - const responseIdToCompositeKey = new Map<number, string>(); - for (const row of rows) { - if (row.responseId) { - responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); - } - if (row.commercialResponseId) { - responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); - } - } - - // responseId별 첨부파일을 복합 키별로 그룹화 - for (const [responseId, files] of filesByResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(responseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // commercialResponseId별 첨부파일을 복합 키별로 그룹화 - for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(commercialResponseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // [16] 최종 데이터 병합 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByCompositeKey.get(compositeKey) || [], - files: filesByCompositeKey.get(compositeKey) || [], - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`all-cbe-vendors-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: ["all-cbe-vendors"], - } - )(); -}
\ No newline at end of file |
