"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMaterials, vendors, vendorsWithTypesView, vendorsWithTypesAndMaterialsView, vendorTypes, type Vendor, pqLists } from "@/db/schema"; import logger from '@/lib/logger'; import * as z from "zod" import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { saveDRMFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { rfqLastVendorResponses, vendorQuotationView } from "@/db/schema/rfqVendor"; import { rfqsLast, rfqLastDetails } from "@/db/schema/rfqLast"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { insertVendor, updateVendor, updateVendors, groupByStatus, getVendorById, getVendorContactsById, selectVendorContacts, countVendorContacts, insertVendorContact, selectVendorItems, countVendorItems, insertVendorItem, countRfqHistory, selectRfqHistory, selectVendorsWithTypesAndMaterials, countVendorsWithTypesAndMaterials, countVendorMaterials, selectVendorMaterials, insertVendorMaterial, getVendorContactById, updateVendorContactById, } from "./repository"; import type { UpdateVendorSchema, GetVendorsSchema, GetVendorContactsSchema, CreateVendorContactSchema, UpdateVendorContactSchema, GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, GetVendorMaterialsSchema, GetBidHistorySchema, } from "./validations"; import { asc, desc, ilike, inArray, and, or, eq, isNull, sql } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items, materials } from "@/db/schema/items"; import { mfaTokens, roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { contractsDetailView, projects, vendorPQSubmissions, vendorsLogs, biddingCompanies, biddings } from "@/db/schema"; import { deleteFile, saveFile, saveBuffer } from "../file-stroage"; import { basicContractTemplates } from "@/db/schema/basicContractDocumnet"; import { basicContract } from "@/db/schema/basicContractDocumnet"; import { headers } from 'next/headers'; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getVendors(input: GetVendorsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 1) 고급 필터 - 새로운 확장 뷰 사용 const advancedWhere = filterColumns({ table: vendorsWithTypesAndMaterialsView, filters: input.filters, joinOperator: input.joinOperator, }); // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(vendorsWithTypesAndMaterialsView.vendorName, s), ilike(vendorsWithTypesAndMaterialsView.vendorCode, s), ilike(vendorsWithTypesAndMaterialsView.email, s), ilike(vendorsWithTypesAndMaterialsView.status, s), // 추가: 업체 유형 검색 ilike(vendorsWithTypesAndMaterialsView.vendorTypeName, s), // 대표품목 검색 추가 ilike(vendorsWithTypesAndMaterialsView.primaryMaterial1, s), ilike(vendorsWithTypesAndMaterialsView.primaryMaterial2, s), ilike(vendorsWithTypesAndMaterialsView.primaryMaterial3, s) ); } // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); // 간단 검색 (advancedTable=false) 시 예시 - 현재 미사용 // const simpleWhere = and( // input.vendorName // ? ilike(vendorsWithTypesAndMaterialsView.vendorName, `%${input.vendorName}%`) // : undefined, // input.status ? ilike(vendorsWithTypesAndMaterialsView.status, input.status) : undefined, // input.country // ? ilike(vendorsWithTypesAndMaterialsView.country, `%${input.country}%`) // : undefined // ); // 실제 사용될 where const where = finalWhere; // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorsWithTypesAndMaterialsView[item.id]) : asc(vendorsWithTypesAndMaterialsView[item.id]) ) : [asc(vendorsWithTypesAndMaterialsView.createdAt)]; // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 (새로운 확장 뷰 사용) const vendorsData = await selectVendorsWithTypesAndMaterials(tx, { where, orderBy, offset, limit: input.perPage, }); // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { const attachments = await tx .select({ id: vendorAttachments.id, fileName: vendorAttachments.fileName, filePath: vendorAttachments.filePath, }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); return { ...vendor, hasAttachments: attachments.length > 0, attachmentsList: attachments, }; }) ); // 3) 전체 개수 const total = await countVendorsWithTypesAndMaterials(tx, where); return { data: vendorsWithAttachments, total }; }); console.log(total) // 페이지 수 const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { console.error("Error fetching vendors:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화 } )(); } export async function getVendorStatusCounts() { return unstable_cache( async () => { try { const initial: Record = { ACTIVE: 0, INACTIVE: 0, BLACKLISTED: 0, "PENDING_REVIEW": 0, "IN_REVIEW": 0, "REJECTED": 0, "IN_PQ": 0, "PQ_FAILED": 0, "PQ_APPROVED": 0, "APPROVED": 0, "READY_TO_SEND": 0, "PQ_SUBMITTED": 0 }; const result = await db.transaction(async (tx) => { const rows = await groupByStatus(tx); return rows.reduce>((acc, { status, count }) => { acc[status] = count; return acc; }, initial); }); return result; } catch (err) { return {} as Record; } }, ["task-status-counts"], // 캐싱 키 { revalidate: 3600, } )(); } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ /** * 신규 Vendor 생성 */ async function storeVendorFiles( tx: PgTransaction, vendorId: number, files: File[], attachmentType: string ) { for (const file of files) { const saveResult = await saveFile({ file, directory: `vendors/${vendorId}` }) // Insert attachment record await tx.insert(vendorAttachments).values({ vendorId, fileName: file.name, filePath: saveResult.publicPath, attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ... }) } } export async function getVendorTypes() { unstable_noStore(); // Next.js server action caching prevention try { const types = await db .select({ id: vendorTypes.id, code: vendorTypes.code, nameKo: vendorTypes.nameKo, nameEn: vendorTypes.nameEn, }) .from(vendorTypes) .orderBy(vendorTypes.nameKo); return { data: types, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; } } export type CreateVendorData = { vendorName: string vendorTypeId: number vendorCode?: string items?: string website?: string taxId: string address?: string email: string phone?: string representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string creditAgency?: string creditRating?: string cashFlowRating?: string corporateRegistrationNumber?: string businessSize?: string country?: string status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" } // Updated createVendor function with taxId duplicate check export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] contacts: { contactName: string contactPosition?: string contactEmail: string contactPhone?: string isPrimary?: boolean }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { return { data: null, error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } // taxId 중복 검사 추가 const existingVendor = await db .select({ id: vendors.id }) .from(vendors) .where(eq(vendors.taxId, vendorData.taxId)) .limit(1); // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 if (existingVendor.length > 0) { return { data: null, error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` }; } await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { vendorName: vendorData.vendorName, vendorCode: vendorData.vendorCode || null, address: vendorData.address || null, country: vendorData.country || null, phone: vendorData.phone || null, email: vendorData.email, website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, vendorTypeId: vendorData.vendorTypeId, items: vendorData.items || null, // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary ?? false, }) } }) revalidateTag("vendors") return { data: null, error: null } } catch (error) { return { data: null, error: getErrorMessage(error) } } } /* ----------------------------------------------------- 3) 업데이트 (단건/복수) ----------------------------------------------------- */ /** 단건 업데이트 */ export async function modifyVendor( input: UpdateVendorSchema & { id: string; userId: number; comment: string; } // userId 추가 ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { // 1. 업데이트 전에 기존 벤더 정보를 가져옴 const existingVendor = await tx.query.vendors.findFirst({ where: eq(vendors.id, parseInt(input.id)), columns: { status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴 }, }); if (!existingVendor) { throw new Error(`Vendor with ID ${input.id} not found`); } const oldStatus = existingVendor.status; // 2. 벤더 정보 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, address: input.address, country: input.country, phone: input.phone, email: input.email, website: input.website, creditAgency: input.creditAgency, creditRating: input.creditRating, cashFlowRating: input.cashFlowRating, status: input.status, isAssociationMember: input.isAssociationMember, }); // 3. 상태가 변경되었다면 로그 기록 if (oldStatus !== input.status) { await tx.insert(vendorsLogs).values({ vendorId: parseInt(input.id), userId: input.userId, action: "status_change", oldStatus, newStatus: input.status, comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`, }); } else if (input.comment) { // 상태 변경이 없더라도 코멘트가 있으면 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: parseInt(input.id), userId: input.userId, action: "vendor_updated", comment: input.comment, }); } return res; }); // 필요 시, status 변경 등에 따른 다른 캐시도 무효화 revalidateTag("vendors"); revalidateTag("rfq-vendors"); return { data: updated, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 업데이트 */ export async function modifyVendors(input: { ids: string[]; status?: Vendor["status"]; }) { unstable_noStore(); try { const data = await db.transaction(async (tx) => { // 여러 협력업체 일괄 업데이트 const [updated] = await updateVendors(tx, input.ids, { // 예: 상태만 일괄 변경 status: input.status, }); return updated; }); revalidateTag("vendors"); if (data.status === input.status) { revalidateTag("vendor-status-counts"); } return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export const findVendorById = async (id: number): Promise => { try { logger.info({ id }, 'Fetching user by ID'); const vendor = await getVendorById(id); if (!vendor) { logger.warn({ id }, 'User not found'); } else { logger.debug({ vendor }, 'User fetched successfully'); } return vendor; } catch (error) { logger.error({ error }, 'Error fetching user by ID'); throw new Error('Failed to fetch user'); } }; export const findVendorContactsById = async (id: number): Promise => { try { logger.info({ id }, 'Fetching user by ID'); const vendor = await getVendorContactsById(id); if (!vendor) { logger.warn({ id }, 'User not found'); } else { logger.debug({ vendor }, 'User fetched successfully'); } return vendor; } catch (error) { logger.error({ error }, 'Error fetching user by ID'); throw new Error('Failed to fetch user'); } }; export async function getVendorContacts(input: GetVendorContactsSchema, id: number) { 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: vendorContacts, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s) , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorContacts.vendorId, id) const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere, vendorWhere ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id]) ) : [asc(vendorContacts.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorContacts(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorContacts(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화 } )(); } export async function createVendorContact(input: CreateVendorContactSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorContact(tx, { vendorId: input.vendorId, contactName: input.contactName, contactPosition: input.contactPosition || "", contactDepartment: input.contactDepartment || "", contactTask: input.contactTask || "", contactEmail: input.contactEmail, contactPhone: input.contactPhone || "", isPrimary: input.isPrimary || false, }); return newContact; }); // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-contacts-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function updateVendorContact(id: number, input: UpdateVendorContactSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { const vendorContact = await getVendorContactById(id); if (!vendorContact) { return { data: null, error: "Contact not found" }; } await db.transaction(async (tx) => { // DB Update await updateVendorContactById(tx, id, { contactName: input.contactName, contactPosition: input.contactPosition, contactDepartment: input.contactDepartment, contactTask: input.contactTask, contactEmail: input.contactEmail, contactPhone: input.contactPhone, isPrimary: input.isPrimary, }); }); // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-contacts-${vendorContact.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } ///item export async function getVendorItems(input: GetVendorItemsSchema, id: number) { const cachedFunction = 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: vendorItemsView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorItemsView.itemCode, s) , ilike(vendorItemsView.description, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorItemsView.vendorId, id) const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere, vendorWhere ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id]) ) : [asc(vendorItemsView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorItems(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorItems(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); console.log(data) return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화 } ); return cachedFunction(); } export async function getVendorMaterials(input: GetVendorMaterialsSchema, id: number) { const cachedFunction = 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: vendorMaterialsView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(vendorMaterialsView.itemCode, s) , ilike(vendorMaterialsView.description, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const vendorWhere = eq(vendorMaterialsView.vendorId, id) const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere, vendorWhere ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(vendorMaterialsView[item.id]) : asc(vendorMaterialsView[item.id]) ) : [asc(vendorMaterialsView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectVendorMaterials(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countVendorMaterials(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); console.log(data) return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`vendor-materials-${id}`], // revalidateTag("tasks") 호출 시 무효화 } ); return cachedFunction(); } export interface ItemDropdownOption { itemCode: string; itemName: string; description: string | null; } /** * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) * 아이템 코드, 이름, 설명만 간소화해서 반환 */ export async function getItemsForVendor(vendorId: number) { return unstable_cache( async () => { try { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db .select({ itemCode: items.itemCode, itemName: items.itemName, description: items.description, }) .from(items) .leftJoin( vendorPossibleItems, eq(items.itemCode, vendorPossibleItems.itemCode) ) // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 .where( isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) ) .orderBy(asc(items.itemName)) return { data: itemsData.map((item) => ({ itemCode: item.itemCode ?? "", // null이라면 ""로 치환 itemName: item.itemName, description: item.description ?? "" // null이라면 ""로 치환 })), error: null } } catch (err) { console.error("Failed to fetch items for vendor dropdown:", err) return { data: [], error: "아이템 목록을 불러오는데 실패했습니다.", } } }, // 캐시 키를 vendorId 별로 달리 해야 한다. ["items-for-vendor", String(vendorId)], { revalidate: 3600, // 1시간 캐싱 tags: ["items"], // revalidateTag("items") 호출 시 무효화 } )() } export async function createVendorItem(input: CreateVendorItemSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorItem(tx, { vendorId: input.vendorId, itemCode: input.itemCode, }); return newContact; }); // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-items-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } const updateVendorItemSchema = z.object({ oldItemCode: z.string().min(1, "Old item code is required"), newItemCode: z.string().min(1, "New item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function deleteVendorItem( vendorId: number, itemCode: string ) { try { const validatedData = deleteVendorItemSchema.parse({ itemCode, vendorId, }) await db .delete(vendorPossibleItems) .where( and( eq(vendorPossibleItems.itemCode, validatedData.itemCode), eq(vendorPossibleItems.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-items-${vendorId}`); return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) return { success: false, message: error instanceof z.ZodError ? error.errors[0].message : "Failed to delete item" } } } export async function updateVendorItem( vendorId: number, oldItemCode: string, newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { const validatedData = updateVendorItemSchema.parse({ oldItemCode, newItemCode, vendorId, }) await db.transaction(async (tx) => { // 기존 아이템 삭제 await tx .delete(vendorPossibleItems) .where( and( eq(vendorPossibleItems.itemCode, validatedData.oldItemCode), eq(vendorPossibleItems.vendorId, validatedData.vendorId) ) ) // 새 아이템 추가 await tx.insert(vendorPossibleItems).values({ vendorId: validatedData.vendorId, itemCode: validatedData.newItemCode, }) }) // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) return { data: null, error: getErrorMessage(err) } } } export async function removeVendorItems(input: { itemCodes: string[] vendorId: number }) { unstable_noStore() try { const validatedData = removeVendorItemsSchema.parse(input) await db .delete(vendorPossibleItems) .where( and( inArray(vendorPossibleItems.itemCode, validatedData.itemCodes), eq(vendorPossibleItems.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-items-${validatedData.vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) return { data: null, error: getErrorMessage(err) } } } // 스키마도 추가해야 합니다 const removeVendorItemsSchema = z.object({ itemCodes: z.array(z.string()).min(1, "At least one item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) const deleteVendorItemSchema = z.object({ itemCode: z.string().min(1, "Item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function getMaterialsForVendor(vendorId: number) { return unstable_cache( async () => { try { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db .select({ itemCode: materials.itemCode, itemName: materials.itemName, description: materials.description, }) .from(materials) .leftJoin( vendorPossibleMaterials, eq(materials.itemCode, vendorPossibleMaterials.itemCode) ) // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 .where( isNull(vendorPossibleMaterials.id) // 또는 isNull(vendorPossibleItems.itemCode) ) .orderBy(asc(materials.itemName)) return { data: itemsData.map((item) => ({ itemCode: item.itemCode ?? "", // null이라면 ""로 치환 itemName: item.itemName, description: item.description ?? "" // null이라면 ""로 치환 })), error: null } } catch (err) { console.error("Failed to fetch items for vendor dropdown:", err) return { data: [], error: "아이템 목록을 불러오는데 실패했습니다.", } } }, // 캐시 키를 vendorId 별로 달리 해야 한다. ["materials-for-vendor", String(vendorId)], { revalidate: 3600, // 1시간 캐싱 tags: ["materials"], // revalidateTag("materials") 호출 시 무효화 } )() } export async function createVendorMaterial(input: CreateVendorItemSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertVendorMaterial(tx, { vendorId: input.vendorId, itemCode: input.itemCode, }); return newContact; }); // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-materials-${input.vendorId}`); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } const updateVendorMaterialSchema = z.object({ oldItemCode: z.string().min(1, "Old item code is required"), newItemCode: z.string().min(1, "New item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function deleteVendorMaterial( vendorId: number, itemCode: string ) { try { const validatedData = deleteVendorItemSchema.parse({ itemCode, vendorId, }) await db .delete(vendorPossibleMaterials) .where( and( eq(vendorPossibleMaterials.itemCode, validatedData.itemCode), eq(vendorPossibleMaterials.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-materials-${vendorId}`); return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) return { success: false, message: error instanceof z.ZodError ? error.errors[0].message : "Failed to delete item" } } } export async function updateVendorMaterial( vendorId: number, oldItemCode: string, newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { const validatedData = updateVendorMaterialSchema.parse({ oldItemCode, newItemCode, vendorId, }) await db.transaction(async (tx) => { // 기존 아이템 삭제 await tx .delete(vendorPossibleMaterials) .where( and( eq(vendorPossibleMaterials.itemCode, validatedData.oldItemCode), eq(vendorPossibleMaterials.vendorId, validatedData.vendorId) ) ) // 새 아이템 추가 await tx.insert(vendorPossibleMaterials).values({ vendorId: validatedData.vendorId, itemCode: validatedData.newItemCode, }) }) // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) return { data: null, error: getErrorMessage(err) } } } export async function removeVendorMaterials(input: { itemCodes: string[] vendorId: number }) { unstable_noStore() try { const validatedData = removeVendormaterialsSchema.parse(input) await db .delete(vendorPossibleMaterials) .where( and( inArray(vendorPossibleMaterials.itemCode, validatedData.itemCodes), eq(vendorPossibleMaterials.vendorId, validatedData.vendorId) ) ) revalidateTag(`vendor-materials-${validatedData.vendorId}`) return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) return { data: null, error: getErrorMessage(err) } } } // 스키마도 추가해야 합니다 const removeVendormaterialsSchema = z.object({ itemCodes: z.array(z.string()).min(1, "At least one item code is required"), vendorId: z.number().min(1, "Vendor ID is required"), }) export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { try { logger.info({ vendorId, input }, "Starting getRfqHistory"); const offset = (input.page - 1) * input.perPage; // 기본 where 조건 (vendorId) const vendorWhere = eq(rfqLastVendorResponses.vendorId, vendorId); logger.debug({ vendorWhere }, "Vendor where condition"); // 고급 필터링 const advancedWhere = filterColumns({ table: rfqsLast, filters: input.filters, joinOperator: input.joinOperator, customColumnMapping: { projectCode: { table: projects, column: "code" }, projectName: { table: projects, column: "name" }, projectInfo: { table: projects, column: "code" }, packageInfo: { table: rfqsLast, column: "packageNo" }, currency: { table: rfqLastVendorResponses, column: "vendorCurrency" }, totalAmount: { table: rfqLastVendorResponses, column: "totalAmount" }, paymentTerms: { table: rfqLastVendorResponses, column: "vendorPaymentTermsCode" }, incoterms: { table: rfqLastVendorResponses, column: "vendorIncotermsCode" }, shippingLocation: { table: rfqLastVendorResponses, column: "vendorPlaceOfShipping" }, leadTime: { table: rfqLastVendorResponses, column: "vendorDeliveryDate" }, contractInfo: { table: rfqLastDetails, column: "contractNo" }, rfqSendDate: { table: rfqsLast, column: "rfqSendDate" }, submittedAt: { table: rfqLastVendorResponses, column: "submittedAt" }, }, }); logger.debug({ advancedWhere }, "Advanced where condition"); // 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(rfqsLast.rfqCode, s), ilike(projects.code, s), ilike(projects.name, s), ilike(rfqsLast.rfqType, s), ilike(rfqsLast.status, s) ); logger.debug({ globalWhere, search: input.search }, "Global search condition"); } const finalWhere = and( advancedWhere, globalWhere, vendorWhere ); logger.debug({ finalWhere }, "Final where condition"); // 정렬 조건 - 동적 매핑 const sortFieldMap: Record = { rfqType: rfqsLast.rfqType, status: rfqsLast.status, rfqCode: rfqsLast.rfqCode, projectInfo: projects.code, projectCode: projects.code, projectName: projects.name, packageNo: rfqsLast.packageNo, packageName: rfqsLast.packageName, packageInfo: rfqsLast.packageNo, materialInfo: projects.code, majorItemMaterialCategory: sql`COALESCE( (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) )`, majorItemMaterialDescription: sql`COALESCE( (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) )`, currency: rfqLastVendorResponses.vendorCurrency, totalAmount: rfqLastVendorResponses.totalAmount, leadTime: rfqLastVendorResponses.vendorDeliveryDate, paymentTerms: rfqLastVendorResponses.vendorPaymentTermsCode, incoterms: rfqLastVendorResponses.vendorIncotermsCode, shippingLocation: rfqLastVendorResponses.vendorPlaceOfShipping, contractInfo: rfqLastDetails.contractNo, rfqSendDate: rfqsLast.rfqSendDate, submittedAt: rfqLastVendorResponses.submittedAt, picName: rfqsLast.picName, }; const orderBy = input.sort.length > 0 ? input.sort.map((item) => { const field = sortFieldMap[item.id]; if (!field) { logger.warn({ sortField: item.id }, "Unknown sort field, using default"); return desc(rfqsLast.rfqSendDate); } return item.desc ? desc(field) : asc(field); }) : [desc(rfqsLast.rfqSendDate)]; logger.debug({ orderBy }, "Order by condition"); // 트랜잭션으로 데이터 조회 const { data, total } = await db.transaction(async (tx) => { logger.debug("Starting transaction for RFQ history query"); // RFQ History 데이터 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) const rfqHistoryData = await tx .select({ id: rfqsLast.id, rfqType: rfqsLast.rfqType, status: rfqsLast.status, rfqCode: rfqsLast.rfqCode, projectCode: projects.code, projectName: projects.name, packageNo: rfqsLast.packageNo, packageName: rfqsLast.packageName, majorItemMaterialCategory: sql`COALESCE( (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) )`, majorItemMaterialDescription: sql`COALESCE( (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) )`, vendorCurrency: rfqLastVendorResponses.vendorCurrency, totalAmount: rfqLastVendorResponses.totalAmount, vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping, vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, contractNo: rfqLastDetails.contractNo, contractStatus: rfqLastDetails.contractStatus, contractCreatedAt: rfqLastDetails.contractCreatedAt, paymentTermsCode: rfqLastDetails.paymentTermsCode, incotermsCode: rfqLastDetails.incotermsCode, placeOfShipping: rfqLastDetails.placeOfShipping, rfqSendDate: rfqsLast.rfqSendDate, submittedAt: rfqLastVendorResponses.submittedAt, picName: rfqsLast.picName, responseStatus: rfqLastVendorResponses.status, responseVersion: rfqLastVendorResponses.responseVersion, }) .from(rfqsLast) .leftJoin(rfqLastVendorResponses, and( eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), eq(rfqLastVendorResponses.vendorId, vendorId) )) .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) .where(finalWhere) .orderBy(...orderBy) .limit(input.perPage) .offset(offset); logger.debug({ dataLength: rfqHistoryData.length }, "RFQ history data fetched"); // 데이터 변환 const data = rfqHistoryData.map(row => ({ id: row.id, rfqType: row.rfqType, status: row.status, rfqCode: ((): string | null => { if (!row.rfqCode) return null; const rev = row.responseVersion ? ` (Rev.${row.responseVersion})` : ''; return `${row.rfqCode}${rev}`; })(), projectInfo: row.projectCode && row.projectName ? `${row.projectCode} (${row.projectName})` : row.projectCode || row.projectName, packageInfo: row.packageNo && row.packageName ? `${row.packageNo} (${row.packageName})` : row.packageNo || row.packageName, materialInfo: row.majorItemMaterialCategory && row.majorItemMaterialDescription ? `${row.majorItemMaterialCategory} (${row.majorItemMaterialDescription})` : row.majorItemMaterialCategory || row.majorItemMaterialDescription, // 견적정보 세부 필드들 currency: row.vendorCurrency, totalAmount: row.totalAmount, leadTime: row.vendorDeliveryDate ?? row.vendorContractDuration ?? null, paymentTerms: row.vendorPaymentTermsCode ?? row.paymentTermsCode ?? null, incoterms: row.vendorIncotermsCode ?? row.incotermsCode ?? null, shippingLocation: row.vendorPlaceOfShipping ?? row.placeOfShipping ?? null, contractInfo: ((): string | null => { const parts: string[] = []; if (row.contractNo) parts.push(String(row.contractNo)); if (row.contractStatus) parts.push(String(row.contractStatus)); if (row.contractCreatedAt) parts.push(new Date(row.contractCreatedAt).toISOString().split('T')[0]); return parts.length ? parts.join(' / ') : null; })(), rfqSendDate: row.rfqSendDate, submittedAt: row.submittedAt, picName: row.picName, vendorStatus: row.responseStatus ?? '미응답' })); // Total count 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) const total = await tx .select({ count: sql`count(*)` }) .from(rfqsLast) .leftJoin(rfqLastVendorResponses, and( eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), eq(rfqLastVendorResponses.vendorId, vendorId) )) .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) .where(finalWhere); const totalCount = total[0]?.count ?? 0; logger.debug({ totalCount }, "RFQ history total count"); return { data, total: totalCount }; }); const pageCount = Math.ceil(total / input.perPage); logger.info({ vendorId, dataLength: data.length, total, pageCount }, "RFQ history query completed"); return { data, pageCount }; } catch (err) { logger.error({ err, vendorId, stack: err instanceof Error ? err.stack : undefined }, 'Error fetching RFQ history'); return { data: [], pageCount: 0 }; } } export async function checkJoinPortal(taxID: string) { try { // 이미 등록된 회사가 있는지 검색 const result = await db.query.vendors.findFirst({ where: eq(vendors.taxId, taxID) }); if (result) { // 이미 가입되어 있음 return { success: false, data: result.vendorName ?? "Already joined", } } // 미가입 → 가입 가능 return { success: true, } } catch (err) { console.error("checkJoinPortal error:", err) // 서버 에러 시 return { success: false, data: "서버 에러가 발생했습니다.", } } } interface CreateCompanyInput { vendorName: string taxId: string email: string address: string phone?: string country?: string // 필요한 필드 추가 가능 (vendorCode, website 등) } /** * 협력업체 첨부파일 다운로드를 위한 서버 액션 * @param vendorId 협력업체 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // API 경로 생성 (단일 파일 또는 모든 파일) const path = fileId ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; // 서버에서는 URL만 반환하고, 클라이언트에서 실제 다운로드 처리 // 파일명도 기본값으로 설정 const fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-attachments.zip`; return { url: path, // 상대 경로 반환 fileName, isServerAction: true // 서버 액션임을 표시 }; } catch (error) { console.error('Download API error:', error); throw error; } } /** * 임시 ZIP 파일 정리를 위한 서버 액션 * @param fileName 정리할 파일명 */ export async function cleanupTempFiles(fileName: string) { 'use server'; try { await deleteFile(`tmp/${fileName}`) return { success: true }; } catch (error) { console.error('임시 파일 정리 오류:', error); return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; } } interface ApproveVendorsInput { ids: number[]; projectId?: number | null } /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 활성화 및 이메일 발송 const result = await db.transaction(async (tx) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ status: "IN_REVIEW", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함) const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, country: vendors.country, // 언어 설정용 국가 정보 }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 3. 각 벤더에 대한 유저 계정 처리 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 // 기존 유저 확인 (비활성 포함) const existingUser = await tx .select({ id: users.id, isActive: users.isActive, language: users.language, }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); let currentUser; if (existingUser.length > 0) { // 🔄 기존 사용자 존재 시 - 활성화 const user = existingUser[0]; console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`); if (!user.isActive) { // 비활성 사용자 활성화 const [activatedUser] = await tx .update(users) .set({ isActive: true, updatedAt: new Date(), }) .where(eq(users.id, user.id)) .returning({ id: users.id }); console.log(`✅ 사용자 활성화 완료: ${vendor.email} (ID: ${user.id})`); currentUser = { id: user.id }; } else { console.log(`ℹ️ 사용자가 이미 활성 상태: ${vendor.email}`); currentUser = { id: user.id }; } } else { // 🆕 새 사용자 생성 (회원가입을 거치지 않은 경우 - 드문 케이스) console.log(`🆕 새 사용자 생성: ${vendor.email}`); // 국가코드에 따른 언어 설정 const language = vendor.country === 'KR' ? 'ko' : 'en'; const [newUser] = await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, companyId: vendor.id, domain: "partners", language, // 국가별 언어 설정 isActive: true, // 승인과 동시에 활성화 // 기본 보안 설정 mfaEnabled: false, isLocked: false, failedLoginAttempts: 0, passwordChangeRequired: true, // 패스워드 설정 필요 requiresConsentUpdate: false, }).returning({ id: users.id }); console.log(`✅ 새 사용자 생성 완료: ${vendor.email} (ID: ${newUser.id}, 언어: ${language})`); currentUser = newUser; } // 4. 역할 할당 (기존 로직 유지) // "Vendor Admin" 역할 찾기 또는 생성 let vendorAdminRole = await tx.query.roles.findFirst({ where: and( eq(roles.name, "Vendor Admin"), eq(roles.domain, "partners"), eq(roles.companyId, vendor.id) ), columns: { id: true } }); // "Vendor Admin" 역할이 없다면 생성 if (!vendorAdminRole) { const [newRole] = await tx.insert(roles).values({ name: "Vendor Admin", domain: "partners", companyId: vendor.id, description: "Vendor Administrator role", }).returning({ id: roles.id }); vendorAdminRole = newRole; console.log(`🎭 새 역할 생성: Vendor Admin (업체: ${vendor.vendorName})`); } // 기존 사용자-역할 관계 확인 const existingUserRole = await tx .select({ userId: userRoles.userId }) .from(userRoles) .where( and( eq(userRoles.userId, currentUser.id), eq(userRoles.roleId, vendorAdminRole.id) ) ) .limit(1); // 역할이 할당되지 않은 경우에만 추가 if (existingUserRole.length === 0) { await tx.insert(userRoles).values({ userId: currentUser.id, roleId: vendorAdminRole.id, }); console.log(`🔗 역할 할당: 사용자 ${currentUser.id} → Vendor Admin`); } else { console.log(`ℹ️ 역할이 이미 할당됨: 사용자 ${currentUser.id}`); } }) ); // 5. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ vendorId: vendorBefore.id, userId: input.userId, action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_REVIEW", comment: "Vendor approved and user account activated", }); }) ); // 6. 각 벤더에게 승인 완료 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { // 사용자 언어 확인 const userInfo = await tx .select({ id: users.id, language: users.language }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); // 새 토큰 생성 (32바이트 랜덤) const resetToken = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료 await db.insert(mfaTokens).values({ userId: userInfo[0].id, token: resetToken, type: 'password_reset', expiresAt, isActive: true, }); const userLang = userInfo.length > 0 ? userInfo[0].language : (vendor.country === 'KR' ? 'ko' : 'en'); const subject = userLang === 'ko' ? "[eVCP] 업체 승인 완료 - 계정 활성화" : "[eVCP] Vendor Approved - Account Activated"; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; const protocol = headersList.get('x-forwarded-proto') || 'http'; const baseUrl = `${protocol}://${host}`; const loginUrl = `${baseUrl}/${userLang}/login`; const passwordSetupUrl = `${baseUrl}/${userLang}/auth/reset-password?token=${resetToken}`; // 패스워드 설정 URL await sendEmail({ to: vendor.email, subject, template: "vendor-approved", // 승인 완료 템플릿 context: { vendorName: vendor.vendorName, loginUrl, passwordSetupUrl, language: userLang, isNewAccount: false, // 기존 계정 활성화임을 표시 }, }); console.log(`📧 승인 완료 이메일 발송: ${vendor.email}`); } catch (emailError) { console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); console.log(`🎉 협력업체 승인 완료: ${updatedVendors.length}개 업체`); return updated; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("users"); // 유저 캐시도 무효화 revalidateTag("roles"); // 역할 캐시도 무효화 revalidateTag("user-roles"); // 유저 역할 캐시도 무효화 return { data: result, error: null }; } catch (err) { console.error("협력업체 승인 처리 오류:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 선택된 벤더의 상태를 REJECTED로 변경하고 이메일 알림을 발송하는 서버 액션 */ export async function rejectVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status, }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ status: "REJECTED", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함) const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, country: vendors.country, // 언어 설정용 국가 정보 }) .from(vendors) .where(inArray(vendors.id, input.ids)); // 3. 각 벤더에 대한 유저 계정 비활성화 처리 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 // 기존 유저 확인 const existingUser = await tx .select({ id: users.id, isActive: users.isActive, language: users.language, }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); if (existingUser.length > 0) { // 기존 사용자 존재 시 - 비활성화 const user = existingUser[0]; console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`); if (user.isActive) { // 활성 사용자 비활성화 await tx .update(users) .set({ isActive: false, updatedAt: new Date(), }) .where(eq(users.id, user.id)); console.log(`❌ 사용자 비활성화 완료: ${vendor.email} (ID: ${user.id})`); } else { console.log(`ℹ️ 사용자가 이미 비활성 상태: ${vendor.email}`); } } }) ); // 4. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ vendorId: vendorBefore.id, userId: input.userId, action: "status_change", oldStatus: vendorBefore.status, newStatus: "REJECTED", comment: "Vendor rejected", }); }) ); // 5. 각 벤더에게 거절 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { // 사용자 언어 확인 const userInfo = await tx .select({ id: users.id, language: users.language }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); const userLang = userInfo.length > 0 ? userInfo[0].language : (vendor.country === 'KR' ? 'ko' : 'en'); const subject = userLang === 'ko' ? "[eVCP] 업체 등록 거절 안내" : "[eVCP] Vendor Registration Rejected"; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; const protocol = headersList.get('x-forwarded-proto') || 'http'; const baseUrl = `${protocol}://${host}`; const loginUrl = `${baseUrl}/${userLang}/login`; await sendEmail({ to: vendor.email, subject, template: "vendor-rejected", // 거절 템플릿 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, }, }); console.log(`📧 거절 이메일 발송: ${vendor.email}`); } catch (emailError) { console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); console.log(`❌ 협력업체 거절 완료: ${updatedVendors.length}개 업체`); return updated; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("users"); // 유저 캐시도 무효화 return { data: result, error: null }; } catch (err) { console.error("협력업체 거절 처리 오류:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 유니크한 PQ 번호 생성 함수 * * 형식: PQ-YYMMDD-XXXXX * YYMMDD: 연도(YY), 월(MM), 일(DD) * XXXXX: 시퀀스 번호 (00001부터 시작) * * 예: PQ-240520-00001, PQ-240520-00002, ... */ export async function generatePQNumber(isProject: boolean = false) { try { // 현재 날짜 가져오기 const now = new Date(); const year = now.getFullYear().toString().slice(-2); // 년도의 마지막 2자리 const month = (now.getMonth() + 1).toString().padStart(2, '0'); // 월 (01-12) const day = now.getDate().toString().padStart(2, '0'); // 일 (01-31) const dateStr = `${year}${month}${day}`; // 접두사 설정 (일반 PQ vs 프로젝트 PQ) const prefix = isProject ? "PPQ" : "PQ"; const datePrefix = `${prefix}-${dateStr}`; // 오늘 생성된 가장 큰 시퀀스 번호 조회 const latestPQ = await db .select({ pqNumber: vendorPQSubmissions.pqNumber }) .from(vendorPQSubmissions) .where( sql`${vendorPQSubmissions.pqNumber} LIKE ${datePrefix + '-%'}` ) .orderBy(desc(vendorPQSubmissions.pqNumber)) .limit(1); let sequenceNumber = 1; // 기본값은 1 // 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산 if (latestPQ.length > 0 && latestPQ[0].pqNumber) { const lastPQ = latestPQ[0].pqNumber; const lastSequence = lastPQ.split('-')[2]; if (lastSequence && !isNaN(parseInt(lastSequence))) { sequenceNumber = parseInt(lastSequence) + 1; } } // 5자리 시퀀스 번호로 포맷팅 (00001, 00002, ...) const formattedSequence = sequenceNumber.toString().padStart(5, '0'); // 최종 PQ 번호 생성 const pqNumber = `${datePrefix}-${formattedSequence}`; return pqNumber; } catch (error) { console.error('Error generating PQ number:', error); // 문제 발생 시 대체 번호 생성 (타임스탬프 기반) const timestamp = Date.now().toString(); const prefix = isProject ? "PPQ" : "PQ"; return `${prefix}-${timestamp}`; } } interface SendVendorsInput { ids: number[]; } /** * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션 */ export async function sendVendors(input: SendVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await db.query.vendors.findMany({ where: and( inArray(vendors.id, input.ids), eq(vendors.status, "APPROVED") ) }); if (!approvedVendors.length) { throw new Error("No approved vendors found in the selection"); } // 벤더별 처리 결과를 저장할 배열 const results = []; // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { // 2-1. 협력업체 연락처 정보 조회 const contacts = await db.query.vendorContacts.findMany({ where: eq(vendorContacts.vendorId, vendor.id) }); // 2-2. 협력업체 가능 아이템 조회 const possibleItems = await db.query.vendorPossibleItems.findMany({ where: eq(vendorPossibleItems.vendorId, vendor.id) }); // 2-3. 협력업체 첨부파일 조회 const attachments = await db.query.vendorAttachments.findMany({ where: eq(vendorAttachments.vendorId, vendor.id), columns: { id: true, fileName: true, filePath: true } }); // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { id: vendor.id, vendorName: vendor.vendorName, taxId: vendor.taxId, address: vendor.address || "", country: vendor.country || "", phone: vendor.phone || "", email: vendor.email || "", website: vendor.website || "", contacts, possibleItems, attachments, }; try { // 내부 API 호출 (기간계 시스템 연동 API) const erpResponse = await fetch(`/api/erp/vendors`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(vendorData), }); if (!erpResponse.ok) { const errorData = await erpResponse.json(); throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`); } const responseData = await erpResponse.json(); if (!responseData.success || !responseData.vendorCode) { throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); } // 2-5. 협력업체 코드 및 상태 업데이트 const vendorCode = responseData.vendorCode; const [updated] = await tx .update(vendors) .set({ vendorCode, status: "ACTIVE", // 상태를 ACTIVE로 변경 updatedAt: new Date(), }) .where(eq(vendors.id, vendor.id)) .returning(); // 2-6. 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: input.userId, action: "status_change", oldStatus: "APPROVED", newStatus: "ACTIVE", comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`, }); const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 2-7. 벤더에게 알림 이메일 발송 if (vendor.email) { const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Vendor Registration Completed"; const portalUrl = `http://${host}/en/partners`; await sendEmail({ to: vendor.email, subject, template: "vendor-active", context: { vendorName: vendor.vendorName, vendorCode, portalUrl, language: userLang, }, }); } results.push({ id: vendor.id, success: true, vendorCode, message: "Successfully sent to ERP system", }); } catch (vendorError) { // 개별 협력업체 처리 오류 기록 results.push({ id: vendor.id, success: false, error: getErrorMessage(vendorError), }); // 에러가 발생해도 로그는 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: input.userId, action: "erp_send_failed", comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`, }); } } // 3. 처리 결과 반환 const successCount = results.filter(r => r.success).length; const failCount = results.filter(r => !r.success).length; return { totalProcessed: results.length, successCount, failCount, results, }; }); // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); return { data: result, error: null }; } catch (err) { console.error("Error sending vendors to ERP:", err); return { data: null, error: getErrorMessage(err) }; } } interface RequestInfoProps { ids: number[]; userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함 } export async function requestInfo({ ids, userId }: RequestInfoProps) { try { return await db.transaction(async (tx) => { // 1. 협력업체 정보 가져오기 const vendorList = await tx.query.vendors.findMany({ where: inArray(vendors.id, ids), }); if (!vendorList.length) { return { error: "협력업체 정보를 찾을 수 없습니다." }; } const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 for (const vendor of vendorList) { // 로그 기록 await tx.insert(vendorsLogs).values({ vendorId: vendor.id, userId: userId, action: "info_requested", comment: "추가 정보 요청됨", }); // 이메일이 없는 경우 스킵 if (!vendor.email) continue; // 협력업체 정보 페이지 URL 생성 const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`; // 벤더에게 이메일 보내기 await sendEmail({ to: vendor.email, subject: "[EVCP] 추가 정보 요청 / Additional Information Request", template: "vendor-additional-info", context: { vendorName: vendor.vendorName, vendorInfoUrl: vendorInfoUrl, language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 }, }); } // 3. 성공적으로 처리됨 return { success: true }; }); } catch (error) { console.error("협력업체 정보 요청 중 오류 발생:", error); return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; } } export async function getVendorDetailById(id: number) { try { // View를 통해 협력업체 정보 조회 const vendor = await db .select() .from(vendorDetailView) .where(eq(vendorDetailView.id, id)) .limit(1) .then(rows => rows[0] || null); if (!vendor) { return null; } console.log("vendor", vendor.attachments) // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱 const contacts = typeof vendor.contacts === 'string' ? JSON.parse(vendor.contacts) : vendor.contacts; const attachments = typeof vendor.attachments === 'string' ? JSON.parse(vendor.attachments) : vendor.attachments; // 파싱된 데이터로 반환 return { ...vendor, contacts, attachments }; } catch (error) { console.error("Error fetching vendor detail:", error); throw new Error("Failed to fetch vendor detail"); } } export type UpdateVendorInfoData = { id: number vendorName: string website?: string address?: string email: string phone?: string country?: string representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string corporateRegistrationNumber?: string creditAgency?: string creditRating?: string cashFlowRating?: string } export type ContactInfo = { id?: number contactName: string contactPosition?: string contactEmail: string contactPhone?: string isPrimary?: boolean } /** * 협력업체 정보를 업데이트하는 함수 */ export async function updateVendorInfo(params: { vendorData: UpdateVendorInfoData files?: File[] creditRatingFiles?: File[] cashFlowRatingFiles?: File[] signatureFiles?: File[] // 서명/직인 파일들 contacts: ContactInfo[] filesToDelete?: number[] // 삭제할 파일 ID 목록 selectedAttachmentType?: string // 선택된 첨부파일 타입 }) { try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], signatureFiles = [], contacts, filesToDelete = [], selectedAttachmentType = "GENERAL" } = params // 세션 및 권한 확인 const session = await getServerSession(authOptions) if (!session?.user || !session.user.companyId) { return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." }; } const companyId = Number(session.user.companyId); // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능) if ( // !session.user.isAdmin && vendorData.id !== companyId) { return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." }; } // 트랜잭션으로 업데이트 수행 await db.transaction(async (tx) => { // 1. 협력업체 정보 업데이트 await tx.update(vendors).set({ vendorName: vendorData.vendorName, address: vendorData.address || null, email: vendorData.email, phone: vendorData.phone || null, website: vendorData.website || null, country: vendorData.country || null, representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, updatedAt: new Date(), }).where(eq(vendors.id, vendorData.id)) // 2. 연락처 정보 관리 // 2-1. 기존 연락처 가져오기 const existingContacts = await tx .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorData.id)) // 2-2. 기존 연락처 ID 목록 const existingContactIds = existingContacts.map(c => c.id) // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류 const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id)) const contactsToAdd = contacts.filter(c => !c.id) // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것) const contactIdsToKeep = contactsToUpdate.map(c => c.id) .filter((id): id is number => id !== undefined) const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id)) // 2-5. 연락처 삭제 if (contactIdsToDelete.length > 0) { await tx .delete(vendorContacts) .where(and( eq(vendorContacts.vendorId, vendorData.id), inArray(vendorContacts.id, contactIdsToDelete) )) } // 2-6. 연락처 업데이트 for (const contact of contactsToUpdate) { if (contact.id !== undefined) { await tx .update(vendorContacts) .set({ contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary || false, updatedAt: new Date(), }) .where(and( eq(vendorContacts.id, contact.id), eq(vendorContacts.vendorId, vendorData.id) )) } } // 2-7. 연락처 추가 for (const contact of contactsToAdd) { await tx .insert(vendorContacts) .values({ vendorId: vendorData.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary || false, }) } // 3. 파일 삭제 처리 if (filesToDelete.length > 0) { // 3-1. 삭제할 파일 정보 가져오기 const attachmentsToDelete = await tx .select() .from(vendorAttachments) .where(and( eq(vendorAttachments.vendorId, vendorData.id), inArray(vendorAttachments.id, filesToDelete) )) // 3-2. 파일 시스템에서 파일 삭제 for (const attachment of attachmentsToDelete) { try { await deleteFile(attachment.filePath) } catch (error) { console.warn(`Failed to delete file for attachment ${attachment.id}:`, error) // 파일 삭제 실패해도 DB에서는 삭제 진행 } } // 3-3. DB에서 파일 기록 삭제 await tx .delete(vendorAttachments) .where(and( eq(vendorAttachments.vendorId, vendorData.id), inArray(vendorAttachments.id, filesToDelete) )) } // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용) // 4-1. 일반 파일 저장 (선택된 타입 사용) if (files.length > 0) { await storeVendorFiles(tx, vendorData.id, files, selectedAttachmentType); } // 4-2. 신용평가 파일 저장 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING"); } // 4-3. 현금흐름 파일 저장 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING"); } // 4-4. 서명/직인 파일 저장 if (signatureFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, signatureFiles, "SIGNATURE"); } }) // 캐시 무효화 revalidateTag("vendors") revalidateTag(`vendor-${vendorData.id}`) return { data: { success: true, message: '협력업체 정보가 성공적으로 업데이트되었습니다.', vendorId: vendorData.id }, error: null } } catch (error) { console.error("Vendor info update error:", error); return { data: null, error: getErrorMessage(error) } } } export interface VendorsLogWithUser { id: number vendorCandidateId: number userId: number userName: string | null userEmail: string | null action: string oldStatus: string | null newStatus: string | null comment: string | null createdAt: Date } export async function getVendorLogs(vendorId: number): Promise { try { const logs = await db .select({ id: vendorsLogs.id, vendorCandidateId: vendorsLogs.vendorId, userId: vendorsLogs.userId, action: vendorsLogs.action, oldStatus: vendorsLogs.oldStatus, newStatus: vendorsLogs.newStatus, comment: vendorsLogs.comment, createdAt: vendorsLogs.createdAt, // 조인한 users 테이블 필드 userName: users.name, userEmail: users.email, }) .from(vendorsLogs) .leftJoin(users, eq(vendorsLogs.userId, users.id)) .where(eq(vendorsLogs.vendorId, vendorId)) .orderBy(desc(vendorsLogs.createdAt)) return logs } catch (error) { console.error("Failed to fetch candidate logs with user info:", error) throw error } } /** * 엑셀 내보내기용 벤더 연락처 목록 조회 * - 페이지네이션 없이 모든 연락처 반환 */ export async function exportVendorContacts(vendorId: number) { try { const contacts = await db .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorId)) .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); return contacts; } catch (error) { console.error("Failed to export vendor contacts:", error); return []; } } /** * 엑셀 내보내기용 벤더 아이템 목록 조회 * - 페이지네이션 없이 모든 아이템 정보 반환 */ export async function exportVendorItems(vendorId: number) { try { const vendorItems = await db .select({ id: vendorItemsView.vendorItemId, vendorId: vendorItemsView.vendorId, itemName: vendorItemsView.itemName, itemCode: vendorItemsView.itemCode, description: vendorItemsView.description, createdAt: vendorItemsView.createdAt, updatedAt: vendorItemsView.updatedAt, }) .from(vendorItemsView) .where(eq(vendorItemsView.vendorId, vendorId)) .orderBy(vendorItemsView.itemName); return vendorItems; } catch (error) { console.error("Failed to export vendor items:", error); return []; } } /** * 엑셀 내보내기용 벤더 RFQ 목록 조회 * - 페이지네이션 없이 모든 RFQ 정보 반환 */ export async function exportVendorRFQs(vendorId: number) { try { const rfqs = await db .select() .from(vendorRfqView) .where(eq(vendorRfqView.vendorId, vendorId)) .orderBy(vendorRfqView.rfqVendorUpdated); return rfqs; } catch (error) { console.error("Failed to export vendor RFQs:", error); return []; } } /** * 엑셀 내보내기용 벤더 계약 목록 조회 * - 페이지네이션 없이 모든 계약 정보 반환 */ export async function exportVendorContracts(vendorId: number) { try { const contracts = await db .select() .from(contractsDetailView) .where(eq(contractsDetailView.vendorId, vendorId)) .orderBy(contractsDetailView.createdAt); return contracts; } catch (error) { console.error("Failed to export vendor contracts:", error); return []; } } /** * 엑셀 내보내기용 벤더 정보 조회 * - 페이지네이션 없이 모든 벤더 정보 반환 */ export async function exportVendorDetails(vendorIds: number[]) { try { if (!vendorIds.length) return []; // 벤더 기본 정보 조회 const vendorsData = await db .select({ id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, taxId: vendors.taxId, address: vendors.address, country: vendors.country, phone: vendors.phone, email: vendors.email, website: vendors.website, status: vendors.status, representativeName: vendors.representativeName, representativeBirth: vendors.representativeBirth, representativeEmail: vendors.representativeEmail, representativePhone: vendors.representativePhone, corporateRegistrationNumber: vendors.corporateRegistrationNumber, creditAgency: vendors.creditAgency, creditRating: vendors.creditRating, cashFlowRating: vendors.cashFlowRating, createdAt: vendors.createdAt, updatedAt: vendors.updatedAt, }) .from(vendors) .where( vendorIds.length === 1 ? eq(vendors.id, vendorIds[0]) : inArray(vendors.id, vendorIds) ); // 벤더별 상세 정보를 포함하여 반환 const vendorsWithDetails = await Promise.all( vendorsData.map(async (vendor) => { // 연락처 조회 const contacts = await exportVendorContacts(vendor.id); // 아이템 조회 const items = await exportVendorItems(vendor.id); // RFQ 조회 const rfqs = await exportVendorRFQs(vendor.id); // 계약 조회 const contracts = await exportVendorContracts(vendor.id); return { ...vendor, vendorContacts: contacts, vendorItems: items, vendorRfqs: rfqs, vendorContracts: contracts, }; }) ); return vendorsWithDetails; } catch (error) { console.error("Failed to export vendor details:", error); return []; } } /** * 벤더 검색 (검색어 기반, 최대 100개) * RFQ 벤더 추가 시 사용 */ export async function searchVendors(searchTerm: string = "", limit: number = 100) { try { let whereCondition; if (searchTerm.trim()) { const s = `%${searchTerm.trim()}%`; whereCondition = or( ilike(vendorsWithTypesView.vendorName, s), ilike(vendorsWithTypesView.vendorCode, s) ); } const vendors = await db .select({ id: vendorsWithTypesView.id, vendorName: vendorsWithTypesView.vendorName, vendorCode: vendorsWithTypesView.vendorCode, status: vendorsWithTypesView.status, country: vendorsWithTypesView.country, }) .from(vendorsWithTypesView) .where( and( whereCondition, // ACTIVE 상태인 벤더만 검색 // eq(vendorsWithTypesView.status, "ACTIVE"), ) ) .orderBy(asc(vendorsWithTypesView.vendorName)) .limit(limit); return vendors; } catch (error) { console.error("벤더 검색 오류:", error); return []; } } /** * 벤더 기본정보 조회 (Basic Info 페이지용) * vendorsWithTypesView를 사용하여 기본 정보 + contacts + attachments 조회 */ export async function getVendorBasicInfo(vendorId: number) { unstable_noStore(); try { return await db.transaction(async (tx) => { // 1. 기본 벤더 정보 조회 (vendorsWithTypesView 사용) const vendor = await tx .select() .from(vendorsWithTypesView) .where(eq(vendorsWithTypesView.id, vendorId)) .limit(1) .then(rows => rows[0] || null); if (!vendor) { return null; } // 2. 연락처 정보 조회 const contacts = await tx .select() .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorId)) .orderBy(desc(vendorContacts.isPrimary), asc(vendorContacts.contactName)); // 3. 첨부파일 정보 조회 const attachments = await tx .select() .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendorId)) .orderBy(asc(vendorAttachments.createdAt)); // 4. 타입 변환하여 반환 (추후 확장 가능하도록 구조화) return { // 기본 벤더 정보 id: vendor.id, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode, taxId: vendor.taxId, address: vendor.address, addressDetail: vendor.addressDetail || "", postalCode: vendor.postalCode || "", businessSize: vendor.businessSize || "", // vendorsWithTypesView에 businessSize 필드가 없을 경우 대비 country: vendor.country, phone: vendor.phone, fax: vendor.fax || null, // vendorsWithTypesView에 fax 필드가 없을 경우 대비 email: vendor.email, website: vendor.website, status: vendor.status, isAssociationMember: vendor.isAssociationMember, representativeName: vendor.representativeName, representativeBirth: vendor.representativeBirth, representativeEmail: vendor.representativeEmail, representativePhone: vendor.representativePhone, representativeWorkExperience: vendor.representativeWorkExperience ?? false, // vendorsWithTypesView에 해당 필드가 없을 경우 false로 기본값 corporateRegistrationNumber: vendor.corporateRegistrationNumber, creditAgency: vendor.creditAgency, creditRating: vendor.creditRating, cashFlowRating: vendor.cashFlowRating, createdAt: vendor.createdAt, updatedAt: vendor.updatedAt, // 연락처 정보 contacts: contacts.map(contact => ({ id: contact.id, contactName: contact.contactName, contactPosition: contact.contactPosition, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone, isPrimary: contact.isPrimary, })), // 첨부파일 정보 attachments: attachments.map(attachment => ({ id: attachment.id, fileName: attachment.fileName, filePath: attachment.filePath, attachmentType: attachment.attachmentType, createdAt: attachment.createdAt, })), // 추가 정보는 임시로 null (나중에 실제 데이터로 교체) additionalInfo: { businessType: vendor.vendorTypeId ? `Type ${vendor.vendorTypeId}` : null, employeeCount: 0, // 실제 데이터가 있을 수 있으므로 유지 mainBusiness: null, }, // 매출 정보 (구현 예정 - 나중에 실제 테이블 연결) salesInfo: null, // 구현 시 { "2023": { totalSales: "1000", totalDebt: "500", ... }, "2022": { ... } } 형태로 연도별 키 사용 // 추가 정보들 (구현 예정 - 나중에 실제 테이블 연결) organization: null, factoryInfo: null, inspectionInfo: null, evaluationInfo: null, classificationInfo: { vendorClassification: null, groupCompany: null, preferredLanguage: "한국어", // 기본값으로 유지 industryType: "제조업", // 기본값으로 유지 isoCertification: null, }, contractDetails: null, capacityInfo: null, // 누락된 필수 필드들 추가 processInfo: { processCount: 0, processPIC: "", processApprovalDate: "", implementationApproval: "" }, contractInfo: { contractRegistrationNumber: "", contractPeriod: "", lastEquipmentInspection: "" }, calculatedMetrics: null, // 구현 시 { "20231231": { debtRatio: 0, ... }, "20221231": { ... } } 형태로 YYYYMMDD 키 사용 }; }); } catch (error) { console.error("Error fetching vendor basic info:", error); return null; } } interface RequestBasicContractInfoProps { vendorIds: number[]; requestedBy: number; templateId: number; pdfBuffer?: Buffer | Uint8Array | ArrayBuffer; // 생성된 PDF 버퍼 (선택적, 다양한 타입 지원) } export async function requestPQVendors(input: ApproveVendorsInput & { userId: number, agreements?: Record, dueDate?: string | null, type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", extraNote?: string, pqItems?: string | Array<{itemCode: string, itemName: string}>, templateId?: number | null }) { unstable_noStore(); const session = await getServerSession(authOptions); const requesterId = session?.user?.id ? Number(session.user.id) : null; try { let projectInfo = null; if (input.projectId) { const project = await db .select({ id: projects.id, projectCode: projects.code, projectName: projects.name, }) .from(projects) .where(eq(projects.id, input.projectId)) .limit(1); if (project.length > 0) { projectInfo = project[0]; } } // PQ 리스트 정보 조회 및 문항 검사 const pqType = input.type || "GENERAL"; const pqListConditions = [ eq(pqLists.type, pqType), eq(pqLists.isDeleted, false) ]; if (input.projectId) { pqListConditions.push(eq(pqLists.projectId, input.projectId)); } else { pqListConditions.push(isNull(pqLists.projectId)); } const pqList = await db .select() .from(pqLists) .where(and(...pqListConditions)) .limit(1) .then(rows => rows[0]); // PQ 리스트가 존재하지 않으면 요청 불가 if (!pqList) { return { success: false, error: input.projectId ? "프로젝트 PQ 리스트를 찾을 수 없습니다" : "일반 PQ 리스트를 찾을 수 없습니다" }; } // PQ 리스트에 문항이 있는지 확인 const { getPqListCriteriaCount } = await import("@/lib/pq/service"); const criteriaCount = await getPqListCriteriaCount(pqList.id); if (criteriaCount === 0) { return { success: false, error: "PQ 리스트에 문항이 없습니다. 문항을 추가한 후 요청해주세요" }; } const result = await db.transaction(async (tx) => { const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status }) .from(vendors) .where(inArray(vendors.id, input.ids)); const [updated] = await tx .update(vendors) .set({ status: "IN_PQ", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) .from(vendors) .where(inArray(vendors.id, input.ids)); const pqType = input.type; const currentDate = new Date(); const existingSubmissions = await tx .select({ vendorId: vendorPQSubmissions.vendorId }) .from(vendorPQSubmissions) .where( and( inArray(vendorPQSubmissions.vendorId, input.ids), pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ); const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); if (newVendorIds.length > 0) { const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { const pqNumber = await generatePQNumber(pqType === "PROJECT"); return { vendorId, pqNumber, projectId: input.projectId || null, type: pqType, status: "REQUESTED", requesterId: input.userId || requesterId, dueDate: input.dueDate ? new Date(input.dueDate) : null, agreements: input.agreements ?? {}, pqItems: input.pqItems || null, createdAt: currentDate, updatedAt: currentDate, }; }); const vendorPQData = await Promise.all(vendorPQDataPromises); await tx.insert(vendorPQSubmissions).values(vendorPQData); } await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ vendorId: vendorBefore.id, userId: input.userId, action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_PQ", comment: input.projectId ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` : "General PQ requested", }); }) ); const headersList = await headers(); const host = headersList.get("host") || "localhost:3000"; await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; try { const userLang = "en"; const vendorPQ = await tx .select({ pqNumber: vendorPQSubmissions.pqNumber }) .from(vendorPQSubmissions) .where( and( eq(vendorPQSubmissions.vendorId, vendor.id), eq(vendorPQSubmissions.type, pqType), input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ) .limit(1) .then((rows) => rows[0]); const subject = input.projectId ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` : input.type === "NON_INSPECTION" ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; const baseUrl = process.env.NEXT_PUBLIC_URL || `http://${host}`; const loginUrl = `${baseUrl}/partners/pq_new`; // 체크된 계약 항목 배열 생성 const contracts = input.agreements ? Object.entries(input.agreements) .filter(([_, checked]) => checked) .map(([name, _]) => name) : []; // PQ 대상 품목 파싱 let pqItemsForEmail = " - "; if (input.pqItems) { try { const items = typeof input.pqItems === 'string' ? JSON.parse(input.pqItems) : input.pqItems; if (Array.isArray(items) && items.length > 0) { pqItemsForEmail = items.map(item => `${item.itemCode} - ${item.itemName}`).join(', '); } else if (typeof input.pqItems === 'string') { pqItemsForEmail = input.pqItems; } } catch (error) { // JSON 파싱 실패 시 문자열 그대로 사용 pqItemsForEmail = typeof input.pqItems === 'string' ? input.pqItems : " - "; } } console.log("loginUrl-pq", loginUrl); await sendEmail({ to: vendor.email, subject, template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", context: { vendorName: vendor.vendorName, vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 pqNumber: vendorPQ?.pqNumber || "", senderName: session?.user?.name || "eVCP", senderEmail: session?.user?.email || "noreply@evcp.com", dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", pqItems: pqItemsForEmail, contracts, extraNote: input.extraNote || "", currentYear: new Date().getFullYear().toString(), loginUrl, language: userLang, projectCode: projectInfo?.projectCode || "", projectName: projectInfo?.projectName || "", hasProject: !!input.projectId, pqType: input.type || "GENERAL", }, }); } catch (emailError) { console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); } }) ); return updated; }); revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("vendor-pq-submissions"); revalidateTag("pq-submissions"); revalidatePath("/evcp/pq_new"); revalidatePath("/partners/pq"); if (input.projectId) { revalidateTag(`project-${input.projectId}`); revalidateTag(`project-pq-submissions-${input.projectId}`); } return { data: result, error: null }; } catch (err) { console.error("Error requesting PQ from vendors:", err); return { data: null, error: getErrorMessage(err) }; } } export async function requestBasicContractInfo({ vendorIds, requestedBy, templateId, pdfBuffer }: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> { unstable_noStore(); if (!vendorIds || vendorIds.length === 0) { return { error: "요청할 협력업체가 선택되지 않았습니다." }; } if (!templateId) { return { error: "계약서 템플릿이 선택되지 않았습니다." }; } try { // 1. 선택된 템플릿 정보 가져오기 const template = await db.query.basicContractTemplates.findFirst({ where: eq(basicContractTemplates.id, templateId) }); if (!template) { return { error: "선택한 템플릿을 찾을 수 없습니다." }; } // 2. PDF 버퍼가 제공된 경우 파일로 저장, 아니면 원본 템플릿 파일 사용 let finalFileName = template.fileName || `${template.templateName}.docx`; let finalFilePath = template.filePath || `/basicContract/${finalFileName}`; if (pdfBuffer) { try { const fileId = uuidv4(); const fileName = `${fileId}.pdf`; // PDF 버퍼를 Buffer로 변환 (saveBuffer 함수가 Buffer 메서드를 사용하므로) let bufferData: Buffer; if (Buffer.isBuffer(pdfBuffer)) { bufferData = pdfBuffer; } else if (pdfBuffer instanceof ArrayBuffer) { bufferData = Buffer.from(pdfBuffer); } else if (pdfBuffer instanceof Uint8Array) { bufferData = Buffer.from(pdfBuffer); } else { bufferData = Buffer.from(pdfBuffer as any); } // saveBuffer 함수를 사용해서 파일 저장 (환경별 경로 처리는 자동으로 됨) const saveResult = await saveBuffer({ buffer: bufferData, fileName: fileName, directory: "basicContract", originalName: `${template.templateName || 'contract'}_${fileId}.pdf`, userId: requestedBy.toString() }); if (!saveResult.success) { throw new Error(saveResult.error || '파일 저장에 실패했습니다.'); } finalFileName = saveResult.fileName || fileName; // publicPath에서 /api/files/ 부분을 제거하여 데이터베이스에 저장 // 이렇게 하면 /api/files/ API를 통해 접근할 때 올바른 경로가 됨 finalFilePath = saveResult.publicPath ? saveResult.publicPath.replace('/api/files/', '') : `/basicContract/${fileName}`; console.log(`✅ PDF 파일 저장 완료: ${saveResult.filePath}`); console.log(`📄 접근 경로: ${finalFilePath}`); } catch (pdfSaveError) { console.error('PDF 파일 저장 오류:', pdfSaveError); return { error: `PDF 파일 저장 실패: ${pdfSaveError instanceof Error ? pdfSaveError.message : '알 수 없는 오류'}` }; } } else if (!template.fileName || !template.filePath) { return { error: "템플릿 파일 정보가 없고 PDF 버퍼도 제공되지 않았습니다." }; } // 3. 협력업체 정보 가져오기 const vendorList = await db .select() .from(vendors) .where(inArray(vendors.id, vendorIds)); if (!vendorList || vendorList.length === 0) { return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; } // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송 const results = await Promise.all( vendorList.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { // 3-1. basic_contract 테이블에 레코드 추가 // 마감일은 생성일로부터 10일 후로 설정 const deadline = new Date(); deadline.setDate(deadline.getDate() + 10); const [newContract] = await db .insert(basicContract) .values({ templateId: template.id, vendorId: vendor.id, requestedBy: requestedBy, status: "PENDING", fileName: finalFileName, // PDF 변환된 파일 이름 사용 filePath: finalFilePath, // PDF 변환된 파일 경로 사용 deadline: deadline.toISOString().split('T')[0], // YYYY-MM-DD 형식으로 변환 }) .returning(); // 이메일 발송은 별도 함수(sendBasicContractEmail)에서 처리 return { vendorId: vendor.id, success: true }; } catch (err) { console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err); return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; } }) ); // 4. 실패한 케이스가 있는지 확인 const failedVendors = results.filter(r => r && !r.success); if (failedVendors.length > 0) { console.error("일부 협력업체 처리 실패:", failedVendors); if (failedVendors.length === vendorIds.length) { // 모든 협력업체 처리 실패 return { error: "모든 협력업체에 대한 처리가 실패했습니다." }; } else { // 일부 협력업체만 처리 실패 return { success: true, error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패` }; } } // 5. 캐시 무효화 revalidateTag("basic-contract-requests"); revalidatePath("/evcp/basic-contract"); revalidatePath("/partners/basic-contract"); revalidatePath("/ko/partners/basic-contract"); revalidatePath("/en/partners/basic-contract"); revalidateTag("basicContractView-vendor"); revalidateTag("basicContractView"); return { success: true }; } catch (error) { console.error("기본계약서 요청 중 오류 발생:", error); return { error: error instanceof Error ? error.message : "기본계약서 요청 처리 중 오류가 발생했습니다." }; } } /** * 기본계약서 이메일 발송 함수 * 협력업체당 하나의 이메일만 발송하며, 전달받은 템플릿 정보를 포함 */ export async function sendBasicContractEmail({ vendorIds, templateIds, requestedBy }: { vendorIds: number[]; templateIds: number[]; requestedBy: number; }): Promise<{ success?: boolean; error?: string }> { if (!vendorIds || vendorIds.length === 0) { return { error: "협력업체 ID가 제공되지 않았습니다." }; } if (!templateIds || templateIds.length === 0) { return { error: "템플릿 ID가 제공되지 않았습니다." }; } try { // 협력업체 정보 가져오기 const vendorList = await db .select() .from(vendors) .where(inArray(vendors.id, vendorIds)); if (!vendorList || vendorList.length === 0) { return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; } // 템플릿 정보 가져오기 const templateList = await db .select() .from(basicContractTemplates) .where(inArray(basicContractTemplates.id, templateIds)); if (!templateList || templateList.length === 0) { return { error: "선택한 템플릿 정보를 찾을 수 없습니다." }; } // 각 협력업체에 이메일 발송 const emailResults = await Promise.all( vendorList.map(async (vendor) => { if (!vendor.email) return { vendorId: vendor.id, success: true }; // 이메일이 없으면 스킵 try { const subject = `기본계약서 서명 요청`; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; const baseUrl = process.env.NEXT_PUBLIC_URL || `http://${host}`; const loginUrl = `${baseUrl}/partners/basic-contract`; // 사용자 언어 설정 (기본값은 한국어) const userLang = "ko"; // 이메일 발송 (요청된 템플릿 정보 포함) await sendEmail({ to: vendor.email, subject, template: "contract-sign-request", context: { vendorName: vendor.vendorName, templates: templateList, // 요청된 템플릿 목록 templateCount: templateList.length, loginUrl, language: userLang, }, }); return { vendorId: vendor.id, success: true }; } catch (err) { console.error(`협력업체 ${vendor.id} 이메일 발송 중 오류:`, err); return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; } }) ); // 실패한 이메일 발송 확인 const failedEmails = emailResults.filter(r => !r.success); if (failedEmails.length > 0) { console.error("일부 협력업체 이메일 발송 실패:", failedEmails); return { success: true, error: `${emailResults.length - failedEmails.length}개 협력업체 이메일 발송 성공, ${failedEmails.length}개 실패` }; } return { success: true }; } catch (error) { console.error("기본계약서 이메일 발송 중 오류 발생:", error); return { error: error instanceof Error ? error.message : "기본계약서 이메일 발송 중 오류가 발생했습니다." }; } } /** * 비밀유지 계약서 첨부파일 저장 서버 액션 */ export async function saveNdaAttachments(input: { vendorIds: number[]; files: File[]; userId: string; }) { unstable_noStore(); try { console.log("📎 비밀유지 계약서 첨부파일 저장 시작"); console.log(`벤더 수: ${input.vendorIds.length}, 파일 수: ${input.files.length}`); const results = []; for (const vendorId of input.vendorIds) { for (const file of input.files) { console.log(`📄 처리 중: 벤더 ID ${vendorId} - ${file.name}`); try { // saveDRMFile을 사용해서 파일 저장 const saveResult = await saveDRMFile( file, decryptWithServerAction, `vendors/nda/${vendorId}`, input.userId ); if (!saveResult.success) { throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`); } // vendor_attachments 테이블에 파일 정보 저장 const insertedAttachment = await db.insert(vendorAttachments).values({ vendorId: vendorId, fileType: file.type || 'application/octet-stream', fileName: saveResult.fileName || file.name, filePath: saveResult.publicPath || '', attachmentType: 'NDA_ATTACHMENT', }).returning(); results.push({ vendorId, fileName: file.name, attachmentId: insertedAttachment[0]?.id || 0, success: true }); console.log(`✅ 완료: 벤더 ID ${vendorId} - ${file.name}`); } catch (error) { console.error(`❌ 실패: 벤더 ID ${vendorId} - ${file.name}`, error); results.push({ vendorId, fileName: file.name, success: false, error: error instanceof Error ? error.message : '알 수 없는 오류' }); } } } // 성공/실패 카운트 const successCount = results.filter(r => r.success).length; const failureCount = results.filter(r => !r.success).length; console.log(`📊 처리 결과: 성공 ${successCount}개, 실패 ${failureCount}개`); // 캐시 무효화 revalidateTag("vendor-attachments"); return { success: true, results, summary: { total: results.length, success: successCount, failure: failureCount } }; } catch (error) { console.error("비밀유지 계약서 첨부파일 저장 중 오류 발생:", error); return { success: false, error: error instanceof Error ? error.message : "첨부파일 저장 처리 중 오류가 발생했습니다." }; } } // 사업자번호(taxId)로 벤더 정보 검색 export async function getVendorByTaxId(taxId: string) { unstable_noStore(); try { const result = await db .select({ id: vendors.id, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, taxId: vendors.taxId, }) .from(vendors) .where(eq(vendors.taxId, taxId)) .limit(1); return { data: result[0] || null, error: null }; } catch (err) { console.error("Error getting vendor by taxId:", err); return { data: null, error: getErrorMessage(err) }; } } export async function getBidHistory(input: GetBidHistorySchema, vendorId: number) { try { const offset = (input.page - 1) * input.perPage; // 기본 where 조건 (vendorId) const vendorWhere = eq(biddingCompanies.companyId, vendorId); // 고급 필터링 const advancedWhere = filterColumns({ table: biddings, filters: input.filters, joinOperator: input.joinOperator, joinedTables: { biddingCompanies, projects, }, customColumnMapping: { biddingManager: biddingCompanies.contactPerson, projectCode: projects.code, }, }); // 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(biddings.biddingNumber, s), ilike(biddings.projectName, s), ilike(biddings.itemName, s), ilike(biddings.title, s) ); } const finalWhere = and( advancedWhere, globalWhere, vendorWhere ); // 정렬 조건 - 동적 매핑 (하드코딩 최소화) // biddings, biddingCompanies, projects 등에서 정렬 가능한 컬럼을 동적으로 매핑 const sortFieldMap: Record = { biddingNumber: biddings.biddingNumber, revision: biddings.revision, contractType: biddings.contractType, biddingType: biddings.biddingType, biddingStatus: biddings.status, projectName: biddings.projectName, itemName: biddings.itemName, biddingTitle: biddings.title, biddingRequestDate: biddings.submissionStartDate, biddingDeadline: biddings.submissionEndDate, biddingManager: biddingCompanies.contactPerson, projectCode: projects.code, createdAt: biddings.createdAt, }; const orderBy = input.sort.length > 0 ? input.sort.map((item) => { const field = sortFieldMap[item.id] ?? biddings.createdAt; return item.desc ? desc(field) : asc(field); }) : [desc(biddings.createdAt)]; // 트랜잭션으로 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 데이터 조회 (biddingCompanies와 biddings 조인) const bidHistoryData = await tx .select({ id: biddingCompanies.id, biddingId: biddings.id, biddingNumber: biddings.biddingNumber, revision: biddings.revision, contractType: biddings.contractType, biddingType: biddings.biddingType, biddingStatus: biddings.status, projectCode: projects.code, projectName: biddings.projectName, itemName: biddings.itemName, // materialGroup: sql`null`, // materialGroupName: sql`null`, biddingTitle: biddings.title, poNumber: sql`null`, contractNumber: sql`null`, biddingRequestDate: biddings.submissionStartDate, biddingDeadline: biddings.submissionEndDate, biddingManager: biddingCompanies.contactPerson, currency: biddings.currency, finalBidPrice: biddingCompanies.finalQuoteAmount, expectedAmount: biddings.targetPrice, awardRatio: biddingCompanies.awardRatio, preQuotePrice: biddingCompanies.preQuoteAmount, createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, }) .from(biddingCompanies) .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) .leftJoin(projects, eq(biddings.projectId, projects.id)) .where(finalWhere) .orderBy(...orderBy) .limit(input.perPage) .offset(offset); const total = await tx .select({ count: sql`count(*)` }) .from(biddingCompanies) .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) .leftJoin(projects, eq(biddings.projectId, projects.id)) .where(finalWhere); const totalCount = total[0]?.count ?? 0; return { data: bidHistoryData, total: totalCount }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { console.error("Error fetching bid history:", err); return { data: [], pageCount: 0 }; } }