diff options
Diffstat (limited to 'lib/procurement-rfqs/services.ts')
| -rw-r--r-- | lib/procurement-rfqs/services.ts | 2055 |
1 files changed, 2055 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts new file mode 100644 index 00000000..7179b213 --- /dev/null +++ b/lib/procurement-rfqs/services.ts @@ -0,0 +1,2055 @@ +"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 { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +import { GetPORfqsSchema, GetQuotationsSchema } from "./validations"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count, between } from "drizzle-orm"; +import { incoterms, paymentTerms, prItems, prItemsView, procurementAttachments, procurementQuotationItems, procurementRfqComments, procurementRfqDetails, procurementRfqDetailsView, procurementRfqs, procurementRfqsView, procurementVendorQuotations } from "@/db/schema/procurementRFQ"; +import { countPORfqs, selectPORfqs } from "./repository"; +import { writeFile, mkdir } from "fs/promises" +import { join } from "path" +import { v4 as uuidv4 } from "uuid" +import { items, projects, users, vendors } from "@/db/schema"; +import { formatISO } from "date-fns"; +import { sendEmail } from "../mail/sendEmail"; +import { formatDate } from "../utils"; + +async function getAuthenticatedUser() { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.id) { + throw new Error("인증이 필요합니다"); + } + + return { + userId: session.user.id, + user: session.user + }; +} + + +export async function getPORfqs(input: GetPORfqsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 필터 처리 - RFQFilterBox에서 오는 필터 + const basicFilters = input.basicFilters || []; + const basicJoinOperator = input.basicJoinOperator || "and"; + + // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 + const advancedFilters = input.filters || []; + const advancedJoinOperator = input.joinOperator || "and"; + + // 기본 필터 조건 생성 + let basicWhere; + if (basicFilters.length > 0) { + basicWhere = filterColumns({ + table: procurementRfqsView, + filters: basicFilters, + joinOperator: basicJoinOperator, + }); + } + + // 고급 필터 조건 생성 + let advancedWhere; + if (advancedFilters.length > 0) { + advancedWhere = filterColumns({ + table: procurementRfqsView, + filters: advancedFilters, + joinOperator: advancedJoinOperator, + }); + } + + // 전역 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(procurementRfqsView.rfqCode, s), + ilike(procurementRfqsView.projectCode, s), + ilike(procurementRfqsView.projectName, s), + ilike(procurementRfqsView.dueDate, s), + ilike(procurementRfqsView.status, s), + // 발주담당 검색 추가 + ilike(procurementRfqsView.picCode, s) + ); + } + + // 날짜 범위 필터링을 위한 특별 처리 (RFQFilterBox는 이미 basicFilters에 포함) + // 이 코드는 기존 처리와의 호환성을 위해 유지 + let dateRangeWhere; + if (input.filters) { + const rfqSendDateFilter = input.filters.find(f => f.id === "rfqSendDate" && Array.isArray(f.value)); + + if (rfqSendDateFilter && Array.isArray(rfqSendDateFilter.value)) { + const [fromDate, toDate] = rfqSendDateFilter.value; + + if (fromDate && toDate) { + // 시작일과 종료일이 모두 있는 경우 + dateRangeWhere = between( + procurementRfqsView.rfqSendDate, + new Date(fromDate), + new Date(toDate) + ); + } else if (fromDate) { + // 시작일만 있는 경우 + dateRangeWhere = sql`${procurementRfqsView.rfqSendDate} >= ${new Date(fromDate)}`; + } + } + } + + // 모든 조건 결합 + let whereConditions = []; + if (basicWhere) whereConditions.push(basicWhere); + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (dateRangeWhere) whereConditions.push(dateRangeWhere); + + // 조건이 있을 때만 and() 사용 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + + + // 정렬 조건 - 안전하게 처리 + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(procurementRfqsView[item.id]) + : asc(procurementRfqsView[item.id]) + ) + : [desc(procurementRfqsView.updatedAt)] + + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectPORfqs(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countPORfqs(tx, finalWhere); + return { data, total }; + }); + + console.log(total) + + console.log("쿼리 결과 데이터:", data.length); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount ,total }; + } catch (err) { + console.error("getRfqs 에러:", err); + + // 에러 세부 정보 더 자세히 로깅 + if (err instanceof Error) { + console.error("에러 메시지:", err.message); + console.error("에러 스택:", err.stack); + + if ('code' in err) { + console.error("SQL 에러 코드:", (err as any).code); + } + } + + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`rfqs-po`], + } + )(); +} + +// RFQ 디테일 데이터를 가져오는 함수 +export async function getRfqDetails(rfqId: number) { + return unstable_cache( + async () => { + try { + unstable_noStore(); + + // SQL 쿼리 직접 실행 + const data = await db + .select() + .from(procurementRfqDetailsView) + .where(eq(procurementRfqDetailsView.rfqId, rfqId)) + + console.log(`RFQ 디테일 SQL 조회 완료: ${rfqId}, ${data?.length}건`); + + return { data }; + } catch (err) { + console.error("RFQ 디테일 SQL 조회 오류:", err); + + if (err instanceof Error) { + console.error("에러 메시지:", err.message); + console.error("에러 스택:", err.stack); + } + + return { data: [] }; + } + }, + [`rfq-details-sql-${rfqId}`], + { + revalidate: 60, + tags: [`rfq-details-${rfqId}`], + } + )(); +} + +// RFQ ID로 디테일 데이터를 가져오는 서버 액션 +export async function fetchRfqDetails(rfqId: number) { + "use server"; + + try { + const result = await getRfqDetails(rfqId); + return result; + } catch (error) { + console.error("RFQ 디테일 서버 액션 오류:", error); + return { data: [] }; + } +} + +// RFQ ID로 PR 상세 항목들을 가져오는 함수 +export async function getPrItemsByRfqId(rfqId: number) { + return unstable_cache( + async () => { + try { + unstable_noStore(); + + + const data = await db + .select() + .from(prItemsView) + .where(eq(prItemsView.procurementRfqsId, rfqId)) + + + console.log(`PR 항목 조회 완료: ${rfqId}, ${data.length}건`); + + return { data }; + } catch (err) { + console.error("PR 항목 조회 오류:", err); + + if (err instanceof Error) { + console.error("에러 메시지:", err.message); + console.error("에러 스택:", err.stack); + } + + return { data: [] }; + } + }, + [`pr-items-${rfqId}`], + { + revalidate: 60, // 1분 캐시 + tags: [`pr-items-${rfqId}`], + } + )(); +} + +// 서버 액션으로 노출할 함수 +export async function fetchPrItemsByRfqId(rfqId: number) { + "use server"; + + try { + const result = await getPrItemsByRfqId(rfqId); + return result; + } catch (error) { + console.error("PR 항목 서버 액션 오류:", error); + return { data: [] }; + } +} + +export async function addVendorToRfq(formData: FormData) { + try { + // 현재 사용자 정보 가져오기 + const { userId, user } = await getAuthenticatedUser(); + console.log("userId", userId); + // rfqId 가져오기 + const rfqId = Number(formData.get("rfqId")) + + if (!rfqId) { + return { + success: false, + message: "RFQ ID가 필요합니다", + } + } + + // 폼 데이터 추출 및 기본 검증 (기존과 동일) + const vendorId = Number(formData.get("vendorId")) + const currency = formData.get("currency") as string + const paymentTermsCode = formData.get("paymentTermsCode") as string + const incotermsCode = formData.get("incotermsCode") as string + const incotermsDetail = formData.get("incotermsDetail") as string || null + const deliveryDate = formData.get("deliveryDate") ? new Date(formData.get("deliveryDate") as string) : null + const taxCode = formData.get("taxCode") as string || null + const placeOfShipping = formData.get("placeOfShipping") as string || null + const placeOfDestination = formData.get("placeOfDestination") as string || null + const materialPriceRelatedYn = formData.get("materialPriceRelatedYn") === "true" + + if (!vendorId || !currency || !paymentTermsCode || !incotermsCode) { + return { + success: false, + message: "필수 항목이 누락되었습니다", + } + } + + // 트랜잭션 시작 + return await db.transaction(async (tx) => { + // 0. 먼저 RFQ 상태 확인 + const rfq = await tx.query.procurementRfqs.findFirst({ + where: eq(procurementRfqs.id, rfqId), + columns: { + id: true, + status: true + } + }); + + if (!rfq) { + throw new Error("RFQ를 찾을 수 없습니다"); + } + console.log("rfq.status", rfq.status); + // 1. RFQ 상세 정보 저장 + const insertedDetails = await tx.insert(procurementRfqDetails).values({ + procurementRfqsId: rfqId, + vendorsId: vendorId, + currency, + paymentTermsCode, + incotermsCode, + incotermsDetail, + deliveryDate: deliveryDate || new Date(), // null이면 현재 날짜 사용 + taxCode, + placeOfShipping, + placeOfDestination, + materialPriceRelatedYn, + updatedBy: Number(userId), + updatedAt: new Date(), + }).returning({ id: procurementRfqDetails.id }); + + if (!insertedDetails || insertedDetails.length === 0) { + throw new Error("RFQ 상세 정보 저장에 실패했습니다"); + } + + const detailId = insertedDetails[0].id; + + + + // 2. RFQ 상태가 "RFQ Created"인 경우 "RFQ Vendor Assignned"로 업데이트 + let statusUpdated = false; + if (rfq.status === "RFQ Created") { + console.log("rfq 상태 업데이트 시작") + await tx.update(procurementRfqs) + .set({ + status: "RFQ Vendor Assignned", + updatedBy: Number(userId), + updatedAt: new Date() + }) + .where(eq(procurementRfqs.id, rfqId)); + + statusUpdated = true; + } + + // 3. 첨부 파일 처리 + const filePromises = []; + const uploadDir = join(process.cwd(), "public", "rfq", rfqId.toString(), "vendors", detailId.toString()); + + // 업로드 디렉토리 생성 + try { + await mkdir(uploadDir, { recursive: true }); + } catch (error) { + console.error("디렉토리 생성 오류:", error); + } + + // FormData에서 file 타입 항목 찾기 + for (const [key, value] of formData.entries()) { + if (key.startsWith("attachment-") && value instanceof File) { + const file = value as File; + + // 파일 크기가 0이면 건너뛰기 + if (file.size === 0) continue; + + // 파일 이름 생성 + const uniqueId = uuidv4(); + const fileName = `${uniqueId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const filePath = join(uploadDir, fileName); + + // 파일을 버퍼로 변환 + const buffer = Buffer.from(await file.arrayBuffer()); + + // 파일 저장 + await writeFile(filePath, buffer); + + // DB에 첨부 파일 정보 저장 + filePromises.push( + tx.insert(procurementAttachments).values({ + attachmentType: 'VENDOR_SPECIFIC', + procurementRfqsId: null, + procurementRfqDetailsId: detailId, + fileName: fileName, + originalFileName: file.name, + filePath: `/uploads/rfq/${rfqId}/vendors/${detailId}/${fileName}`, + fileSize: file.size, + fileType: file.type, + description: `${file.name} - 벤더 ID ${vendorId}용 첨부파일`, + createdBy: Number(userId), + createdAt: new Date(), + }) + ); + } + } + + // 첨부 파일이 있으면 처리 + if (filePromises.length > 0) { + await Promise.all(filePromises); + } + + // 캐시 무효화 (여러 경로 지정 가능) + revalidateTag(`rfq-details-${rfqId}`); + revalidateTag(`rfqs-po`); + + return { + success: true, + message: "벤더 정보가 성공적으로 추가되었습니다", + data: { + id: detailId, + statusUpdated: statusUpdated + }, + }; + }); + + } catch (error) { + console.error("벤더 추가 오류:", error); + return { + success: false, + message: "벤더 추가 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)), + }; + } +} + + +// 벤더 데이터 조회 서버 액션 +export async function fetchVendors() { + try { + const data = await db.select().from(vendors) + + return { + success: true, + data, + } + } catch (error) { + console.error("벤더 데이터 로드 오류:", error) + return { + success: false, + message: "벤더 데이터를 불러오는 데 실패했습니다", + data: [] + } + } +} + +// 통화 데이터 조회 서버 액션 +export async function fetchCurrencies() { + try { + // 통화 테이블이 별도로 없다면 여기서 하드코딩하거나 설정 파일에서 가져올 수도 있습니다 + const data = [ + { code: "KRW", name: "Korean Won" }, + { code: "USD", name: "US Dollar" }, + { code: "EUR", name: "Euro" }, + { code: "JPY", name: "Japanese Yen" }, + { code: "CNY", name: "Chinese Yuan" }, + ] + + return { + success: true, + data, + } + } catch (error) { + console.error("통화 데이터 로드 오류:", error) + return { + success: false, + message: "통화 데이터를 불러오는 데 실패했습니다", + data: [] + } + } +} + +// 지불 조건 데이터 조회 서버 액션 +export async function fetchPaymentTerms() { + try { + const data = await db.select().from(paymentTerms) + + return { + success: true, + data, + } + } catch (error) { + console.error("지불 조건 데이터 로드 오류:", error) + return { + success: false, + message: "지불 조건 데이터를 불러오는 데 실패했습니다", + data: [] + } + } +} + +// 인코텀즈 데이터 조회 서버 액션 +export async function fetchIncoterms() { + try { + const data = await db.select().from(incoterms) + + return { + success: true, + data, + } + } catch (error) { + console.error("인코텀즈 데이터 로드 오류:", error) + return { + success: false, + message: "인코텀즈 데이터를 불러오는 데 실패했습니다", + data: [] + } + } +} + +export async function deleteRfqDetail(detailId: number) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { + success: false, + message: "인증이 필요합니다", + }; + } + + // DB에서 항목 삭제 + await db.delete(procurementRfqDetails) + .where(eq(procurementRfqDetails.id, detailId)); + + // 캐시 무효화 + revalidateTag(`rfq-details-${detailId}`); + + return { + success: true, + message: "RFQ 벤더 정보가 삭제되었습니다", + }; + } catch (error) { + console.error("RFQ 벤더 정보 삭제 오류:", error); + return { + success: false, + message: "RFQ 벤더 정보 삭제 중 오류가 발생했습니다", + }; + } +} + +// RFQ 상세 정보 수정 +export async function updateRfqDetail(detailId: number, data: any) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { + success: false, + message: "인증이 필요합니다", + }; + } + + const userId = Number(session.user.id); + + // 필요한 데이터 추출 + const { + vendorId, + currency, + paymentTermsCode, + incotermsCode, + incotermsDetail, + deliveryDate, + taxCode, + placeOfShipping, + placeOfDestination, + materialPriceRelatedYn, + } = data; + + // DB 업데이트 + await db.update(procurementRfqDetails) + .set({ + vendorsId: Number(vendorId), + currency, + paymentTermsCode, + incotermsCode, + incotermsDetail: incotermsDetail || null, + deliveryDate: deliveryDate ? new Date(deliveryDate) : new Date(), + taxCode: taxCode || null, + placeOfShipping: placeOfShipping || null, + placeOfDestination: placeOfDestination || null, + materialPriceRelatedYn, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(procurementRfqDetails.id, detailId)); + + // 캐시 무효화 + revalidateTag(`rfq-details-${detailId}`); + + return { + success: true, + message: "RFQ 벤더 정보가 수정되었습니다", + }; + } catch (error) { + console.error("RFQ 벤더 정보 수정 오류:", error); + return { + success: false, + message: "RFQ 벤더 정보 수정 중 오류가 발생했습니다", + }; + } +} + +export async function updateRfqRemark(rfqId: number, remark: string) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { + success: false, + message: "인증이 필요합니다", + }; + } + + console.log(rfqId, remark) + + // DB 업데이트 + await db.update(procurementRfqs) + .set({ + remark, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(procurementRfqs.id, rfqId)); + + // 캐시 무효화 + revalidateTag(`rfqs-po`); + revalidatePath("/evcp/po-rfq"); // 경로도 함께 무효화 + + return { + success: true, + message: "비고가 업데이트되었습니다", + }; + } catch (error) { + console.error("비고 업데이트 오류:", error); + return { + success: false, + message: "비고 업데이트 중 오류가 발생했습니다", + }; + } +} + +export async function sealRfq(rfqId: number) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { + success: false, + message: "인증이 필요합니다", + }; + } + + // DB 업데이트 + await db.update(procurementRfqs) + .set({ + rfqSealedYn: true, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(procurementRfqs.id, rfqId)); + + // 캐시 무효화 + revalidateTag(`rfqs-po`); + + return { + success: true, + message: "RFQ가 성공적으로 밀봉되었습니다", + }; + } catch (error) { + console.error("RFQ 밀봉 오류:", error); + return { + success: false, + message: "RFQ 밀봉 중 오류가 발생했습니다", + }; + } +} + +// RFQ 전송 서버 액션 +export async function sendRfq(rfqId: number) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { + success: false, + message: "인증이 필요합니다", + } + } + + // 현재 RFQ 상태 확인 + // RFQ 및 관련 정보 조회 + const rfq = await db.query.procurementRfqs.findFirst({ + where: eq(procurementRfqs.id, rfqId), + columns: { + id: true, + rfqCode: true, + status: true, + dueDate: true, + rfqSendDate: true, + remark: true, + rfqSealedYn: true, + }, + with: { + project: { + columns: { + id: true, + code: true, + name: true, + } + }, + item: { + columns: { + id: true, + itemCode: true, + itemName: true, + } + }, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + }, + prItems: { + columns: { + id: true, + rfqItem: true, // 아이템 번호 + materialCode: true, + materialDescription: true, + quantity: true, + uom: true, + prNo: true, + majorYn: true, + } + } + } + }); + + if (!rfq) { + return { + success: false, + message: "RFQ를 찾을 수 없습니다", + } + } + + if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { + return { + success: false, + message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", + } + } + + const isResend = rfq.status === "RFQ Sent"; + + // 현재 사용자 정보 조회 (CC 용) + const sender = await db.query.users.findFirst({ + where: eq(users.id, Number(session.user.id)), + columns: { + id: true, + email: true, + name: true, + } + }); + + if (!sender || !sender.email) { + return { + success: false, + message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", + } + } + + // RFQ에 할당된 벤더 목록 조회 + const rfqDetails = await db.query.procurementRfqDetails.findMany({ + where: eq(procurementRfqDetails.procurementRfqsId, rfqId), + columns: { + id: true, + vendorsId: true, + currency: true, + paymentTermsCode: true, + incotermsCode: true, + incotermsDetail: true, + deliveryDate: true, + }, + with: { + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + }, + paymentTerms: { + columns: { + code: true, + description: true, + } + }, + incoterms: { + columns: { + code: true, + description: true, + } + } + } + }); + + if (rfqDetails.length === 0) { + return { + success: false, + message: "할당된 벤더가 없습니다", + } + } + + // 트랜잭션 시작 + await db.transaction(async (tx) => { + // 1. RFQ 상태 업데이트 + await tx.update(procurementRfqs) + .set({ + status: "RFQ Sent", + rfqSendDate: new Date(), + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(procurementRfqs.id, rfqId)); + + // 2. 각 벤더에 대해 초기 견적서 레코드 생성 및 이메일 발송 + for (const detail of rfqDetails) { + if (!detail.vendorsId || !detail.vendor) continue; + + // 기존 Draft 견적서가 있는지 확인 + const existingQuotation = await tx.query.procurementVendorQuotations.findFirst({ + where: and( + eq(procurementVendorQuotations.rfqId, rfqId), + eq(procurementVendorQuotations.vendorId, detail.vendorsId) + ), + orderBy: [desc(procurementVendorQuotations.quotationVersion)] + }); + + // 견적서 코드 (기존 것 재사용 또는 신규 생성) + const quotationCode = existingQuotation?.quotationCode || `${rfq.rfqCode}-${detail.vendorsId}`; + + // 버전 관리 - 재전송인 경우 버전 증가 + const quotationVersion = existingQuotation ? ((existingQuotation.quotationVersion? existingQuotation.quotationVersion: 0 )+ 1) : 1; + + // 견적서 레코드 생성 + const insertedQuotation = await tx.insert(procurementVendorQuotations).values({ + rfqId, + vendorId: detail.vendorsId, + quotationCode, + quotationVersion, + totalItemsCount: rfq.prItems.length, + subTotal: "0", + taxTotal: "0", + discountTotal: "0", + totalPrice: "0", + currency: detail.currency || "USD", + // 납품일은 RFQ 납품일보다 조금 이전으로 설정 (기본값) + estimatedDeliveryDate: detail.deliveryDate ? + new Date(detail.deliveryDate.getTime() - 7 * 24 * 60 * 60 * 1000) : // 1주일 전 + undefined, + paymentTermsCode: detail.paymentTermsCode, + incotermsCode: detail.incotermsCode, + incotermsDetail: detail.incotermsDetail, + status: "Draft", + createdBy: Number(session.user.id), + updatedBy: Number(session.user.id), + createdAt: new Date(), + updatedAt: new Date(), + }).returning({ id: procurementVendorQuotations.id }); + + // 새로 생성된 견적서 ID + const quotationId = insertedQuotation[0].id; + + // 3. 각 PR 아이템에 대해 견적 아이템 생성 + for (const prItem of rfq.prItems) { + // procurementQuotationItems에 레코드 생성 + await tx.insert(procurementQuotationItems).values({ + quotationId, + prItemId: prItem.id, + materialCode: prItem.materialCode, + materialDescription: prItem.materialDescription, + quantity: prItem.quantity, + uom: prItem.uom, + // 기본값으로 설정된 필드 + unitPrice: 0, + totalPrice: 0, + currency: detail.currency || "USD", + // 나머지 필드는 null 또는 기본값 사용 + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + // 벤더에 속한 모든 사용자 조회 + const vendorUsers = await db.query.users.findMany({ + where: eq(users.companyId, detail.vendorsId), + columns: { + id: true, + email: true, + name: true, + language: true + } + }); + + // 유효한 이메일 주소만 필터링 + const vendorEmailsString = vendorUsers + .filter(user => user.email) + .map(user => user.email) + .join(", "); + + if (vendorEmailsString) { + // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) + const language = vendorUsers[0]?.language || "en"; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: language, + rfq: { + id: rfq.id, + code: rfq.rfqCode, + title: rfq.item?.itemName || '', + projectCode: rfq.project?.code || '', + projectName: rfq.project?.name || '', + description: rfq.remark || '', + dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A', + deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate) : 'N/A', + }, + vendor: { + id: detail.vendor.id, + code: detail.vendor.vendorCode || '', + name: detail.vendor.vendorName, + }, + sender: { + fullName: sender.name || '', + email: sender.email, + }, + items: rfq.prItems.map(item => ({ + itemNumber: item.rfqItem || '', + materialCode: item.materialCode || '', + description: item.materialDescription || '', + quantity: item.quantity, + uom: item.uom || '', + })), + details: { + currency: detail.currency || 'USD', + paymentTerms: detail.paymentTerms?.description || detail.paymentTermsCode || 'N/A', + incoterms: detail.incoterms ? + `${detail.incoterms.code} ${detail.incotermsDetail || ''}` : + detail.incotermsCode ? `${detail.incotermsCode} ${detail.incotermsDetail || ''}` : 'N/A', + }, + quotationCode: existingQuotation?.quotationCode || `QUO-${rfqId}-${detail.vendorsId}`, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', + isResend: isResend, + quotationVersion: quotationVersion, + versionInfo: isResend ? `(버전 ${quotationVersion})` : '', + }; + + // 이메일 전송 (모든 벤더 이메일을 to 필드에 배열로 전달) + await sendEmail({ + to: vendorEmailsString, + subject: isResend + ? `[RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}` + : `[RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`, + template: 'rfq-notification', + context: emailContext, + cc: sender.email, // 발신자를 CC에 추가 + }); + } + } + }); + + // 캐시 무효화 + revalidateTag(`rfqs-po`); + + return { + success: true, + message: "RFQ가 성공적으로 전송되었습니다", + } + } catch (error) { + console.error("RFQ 전송 오류:", error); + return { + success: false, + message: "RFQ 전송 중 오류가 발생했습니다", + } + } +} +/** + * 첨부파일 타입 정의 + */ +export interface Attachment { + id: number + fileName: string + fileSize: number + fileType: string | null // <- null 허용 + filePath: string + uploadedAt: Date +} + +/** + * 코멘트 타입 정의 + */ +export interface Comment { + id: number + rfqId: number + vendorId: number | null // null 허용으로 변경 + userId?: number | null // null 허용으로 변경 + content: string + isVendorComment: boolean | null // null 허용으로 변경 + createdAt: Date + updatedAt: Date + userName?: string | null // null 허용으로 변경 + vendorName?: string | null // null 허용으로 변경 + attachments: Attachment[] + isRead: boolean | null // null 허용으로 변경 +} + + +/** + * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 + * + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + * @returns 코멘트 목록 + */ +export async function fetchVendorComments(rfqId: number, vendorId?: number): Promise<Comment[]> { + if (!vendorId) { + return [] + } + + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + // 코멘트 쿼리 + const comments = await db.query.procurementRfqComments.findMany({ + where: and( + eq(procurementRfqComments.rfqId, rfqId), + eq(procurementRfqComments.vendorId, vendorId) + ), + orderBy: [procurementRfqComments.createdAt], + with: { + user: { + columns: { + name: true + } + }, + vendor: { + columns: { + vendorName: true + } + }, + attachments: true, + } + }) + + // 결과 매핑 + return comments.map(comment => ({ + id: comment.id, + rfqId: comment.rfqId, + vendorId: comment.vendorId, + userId: comment.userId || undefined, + content: comment.content, + isVendorComment: comment.isVendorComment, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + userName: comment.user?.name, + vendorName: comment.vendor?.vendorName, + isRead: comment.isRead, + attachments: comment.attachments.map(att => ({ + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize, + fileType: att.fileType, + filePath: att.filePath, + uploadedAt: att.uploadedAt + })) + })) + } catch (error) { + console.error('벤더 코멘트 가져오기 오류:', error) + throw error + } +} + +/** + * 코멘트를 읽음 상태로 표시하는 서버 액션 + * + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + */ +export async function markMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> { + if (!vendorId) { + return + } + + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + // 벤더가 작성한 읽지 않은 코멘트 업데이트 + await db.update(procurementRfqComments) + .set({ isRead: true }) + .where( + and( + eq(procurementRfqComments.rfqId, rfqId), + eq(procurementRfqComments.vendorId, vendorId), + eq(procurementRfqComments.isVendorComment, true), + eq(procurementRfqComments.isRead, false) + ) + ) + + // 캐시 무효화 + revalidateTag(`rfq-${rfqId}-comments`) + } catch (error) { + console.error('메시지 읽음 표시 오류:', error) + throw error + } +} + +/** + * 읽지 않은 메시지 개수 가져오기 서버 액션 + * + * @param rfqId RFQ ID + */ +export async function fetchUnreadMessages(rfqId: number): Promise<Record<number, number>> { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다"); + } + + // 쿼리 빌더 방식으로 카운트 조회 - 타입 안전 방식 + const result = await db + .select({ + vendorId: procurementRfqComments.vendorId, + unreadCount: count() + }) + .from(procurementRfqComments) + .where( + and( + eq(procurementRfqComments.rfqId, rfqId), + eq(procurementRfqComments.isVendorComment, true), + eq(procurementRfqComments.isRead, false) + ) + ) + .groupBy(procurementRfqComments.vendorId); + + // 결과 매핑 + const unreadMessages: Record<number, number> = {}; + result.forEach(row => { + if (row.vendorId) { + unreadMessages[row.vendorId] = Number(row.unreadCount); + } + }); + + return unreadMessages; + } catch (error) { + console.error('읽지 않은 메시지 개수 가져오기 오류:', error); + throw error; + } +} + + +/** + * 견적서 업데이트 서버 액션 + */ +export async function updateVendorQuotation(data: { + id: number + quotationVersion?: number + currency?: string + validUntil?: Date + estimatedDeliveryDate?: Date + paymentTermsCode?: string + incotermsCode?: string + incotermsDetail?: string + remark?: string + subTotal?: string + taxTotal?: string + discountTotal?: string + totalPrice?: string + totalItemsCount?: number +}) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { + success: false, + message: "인증이 필요합니다", + } + } + + // 견적서 존재 확인 + const quotation = await db.query.procurementVendorQuotations.findFirst({ + where: eq(procurementVendorQuotations.id, data.id), + }) + + if (!quotation) { + return { + success: false, + message: "견적서를 찾을 수 없습니다", + } + } + + // 권한 확인 (벤더 또는 관리자만 수정 가능) + const isAuthorized = + (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) + + if (!isAuthorized) { + return { + success: false, + message: "견적서 수정 권한이 없습니다", + } + } + + // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) + if (quotation.status !== "Draft" && quotation.status !== "Rejected") { + return { + success: false, + message: "제출되었거나 승인된 견적서는 수정할 수 없습니다", + } + } + + // 업데이트할 데이터 구성 + const updateData: Record<string, any> = { + updatedBy: Number(session.user.id), + updatedAt: new Date(), + } + + // 필드 추가 + if (data.currency) updateData.currency = data.currency + if (data.validUntil) updateData.validUntil = data.validUntil + if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate + if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode + if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode + if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail + if (data.remark !== undefined) updateData.remark = data.remark + if (data.subTotal) updateData.subTotal = data.subTotal + if (data.taxTotal) updateData.taxTotal = data.taxTotal + if (data.discountTotal) updateData.discountTotal = data.discountTotal + if (data.totalPrice) updateData.totalPrice = data.totalPrice + if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount + + // Rejected 상태에서 수정 시 Draft 상태로 변경 + if (quotation.status === "Rejected") { + updateData.status = "Draft" + + // 버전 증가 + if (data.quotationVersion) { + updateData.quotationVersion = data.quotationVersion + 1 + } else { + updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 + } + } + + // 견적서 업데이트 + await db.update(procurementVendorQuotations) + .set(updateData) + .where(eq(procurementVendorQuotations.id, data.id)) + + // 캐시 무효화 + revalidateTag(`quotation-${data.id}`) + revalidateTag(`rfq-${quotation.rfqId}`) + + return { + success: true, + message: "견적서가 업데이트되었습니다", + } + } catch (error) { + console.error("견적서 업데이트 오류:", error) + return { + success: false, + message: "견적서 업데이트 중 오류가 발생했습니다", + } + } +} + +interface QuotationItem { + unitPrice: number; + deliveryDate: Date | null; + status: "Draft" | "Rejected" | "Submitted" | "Approved"; // 상태를 유니온 타입으로 정의 + + // 필요한 다른 속성들도 추가 +} + +/** + * 견적서 제출 서버 액션 + */ +export async function submitVendorQuotation(data: { + id: number + quotationVersion?: number + currency?: string + validUntil?: Date + estimatedDeliveryDate?: Date + paymentTermsCode?: string + incotermsCode?: string + incotermsDetail?: string + remark?: string + subTotal?: string + taxTotal?: string + discountTotal?: string + totalPrice?: string + totalItemsCount?: number +}) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { + success: false, + message: "인증이 필요합니다", + } + } + + // 견적서 존재 확인 + const quotation = await db.query.procurementVendorQuotations.findFirst({ + where: eq(procurementVendorQuotations.id, data.id), + with: { + items: true, + } + }) + + if (!quotation) { + return { + success: false, + message: "견적서를 찾을 수 없습니다", + } + } + + // 권한 확인 (벤더 또는 관리자만 제출 가능) + const isAuthorized = + (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) + + if (!isAuthorized) { + return { + success: false, + message: "견적서 제출 권한이 없습니다", + } + } + + // 상태 확인 (Draft 또는 Rejected 상태만 제출 가능) + if (quotation.status !== "Draft" && quotation.status !== "Rejected") { + return { + success: false, + message: "이미 제출되었거나 승인된 견적서는 다시 제출할 수 없습니다", + } + } + + // 견적 항목 검증 + if (!quotation.items || (quotation.items as QuotationItem[]).length === 0) { + return { + success: false, + message: "견적 항목이 없습니다", + } + } + + // 필수 항목 검증 + const hasEmptyItems = (quotation.items as QuotationItem[]).some(item => + item.unitPrice <= 0 || !item.deliveryDate + ) + + if (hasEmptyItems) { + return { + success: false, + message: "모든 항목의 단가와 납품일을 입력해주세요", + } + } + + // 필수 정보 검증 + if (!data.validUntil || !data.estimatedDeliveryDate) { + return { + success: false, + message: "견적 유효기간과 예상 납품일은 필수 항목입니다", + } + } + + // 업데이트할 데이터 구성 + const updateData: Record<string, any> = { + status: "Submitted", + submittedAt: new Date(), + updatedBy: Number(session.user.id), + updatedAt: new Date(), + } + + // 필드 추가 + if (data.currency) updateData.currency = data.currency + if (data.validUntil) updateData.validUntil = data.validUntil + if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate + if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode + if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode + if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail + if (data.remark !== undefined) updateData.remark = data.remark + if (data.subTotal) updateData.subTotal = data.subTotal + if (data.taxTotal) updateData.taxTotal = data.taxTotal + if (data.discountTotal) updateData.discountTotal = data.discountTotal + if (data.totalPrice) updateData.totalPrice = data.totalPrice + if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount + + // Rejected 상태에서 제출 시 버전 증가 + if (quotation.status === "Rejected") { + updateData.status = "Revised" + + if (data.quotationVersion) { + updateData.quotationVersion = data.quotationVersion + 1 + } else { + updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 + } + } + + // 견적서 업데이트 + await db.update(procurementVendorQuotations) + .set(updateData) + .where(eq(procurementVendorQuotations.id, data.id)) + + // 캐시 무효화 + revalidateTag(`quotation-${data.id}`) + revalidateTag(`rfq-${quotation.rfqId}`) + + return { + success: true, + message: "견적서가 성공적으로 제출되었습니다", + } + } catch (error) { + console.error("견적서 제출 오류:", error) + return { + success: false, + message: "견적서 제출 중 오류가 발생했습니다", + } + } +} + +/** + * 견적 항목 업데이트 서버 액션 + */ +export async function updateQuotationItem(data: { + id: number + unitPrice?: number + totalPrice?: number + vendorMaterialCode?: string + vendorMaterialDescription?: string + deliveryDate?: Date | null + leadTimeInDays?: number + taxRate?: number + taxAmount?: number + discountRate?: number + discountAmount?: number + remark?: string + isAlternative?: boolean + isRecommended?: boolean +}) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { + success: false, + message: "인증이 필요합니다", + } + } + + // 항목 존재 확인 + const item = await db.query.procurementQuotationItems.findFirst({ + where: eq(procurementQuotationItems.id, data.id), + with: { + quotation: true, + } + }) + + if (!item || !item.quotation) { + return { + success: false, + message: "견적 항목을 찾을 수 없습니다", + } + } + + // 권한 확인 (벤더 또는 관리자만 수정 가능) + const isAuthorized = ( + session.user.domain === "partners" && + session.user.companyId === (item.quotation as { vendorId: number }).vendorId + ) + + if (!isAuthorized) { + return { + success: false, + message: "견적 항목 수정 권한이 없습니다", + } + } + + const quotation = item.quotation as Quotation; + + // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) + if (quotation.status !== "Draft" && quotation.status !== "Rejected") { + return { + success: false, + message: "제출되었거나 승인된 견적서의 항목은 수정할 수 없습니다", + } + } + + // 업데이트할 데이터 구성 + const updateData: Record<string, any> = { + updatedAt: new Date(), + } + + // 필드 추가 + if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice + if (data.totalPrice !== undefined) updateData.totalPrice = data.totalPrice + if (data.vendorMaterialCode !== undefined) updateData.vendorMaterialCode = data.vendorMaterialCode + if (data.vendorMaterialDescription !== undefined) updateData.vendorMaterialDescription = data.vendorMaterialDescription + if (data.deliveryDate !== undefined) updateData.deliveryDate = data.deliveryDate + if (data.leadTimeInDays !== undefined) updateData.leadTimeInDays = data.leadTimeInDays + if (data.taxRate !== undefined) updateData.taxRate = data.taxRate + if (data.taxAmount !== undefined) updateData.taxAmount = data.taxAmount + if (data.discountRate !== undefined) updateData.discountRate = data.discountRate + if (data.discountAmount !== undefined) updateData.discountAmount = data.discountAmount + if (data.remark !== undefined) updateData.remark = data.remark + if (data.isAlternative !== undefined) updateData.isAlternative = data.isAlternative + if (data.isRecommended !== undefined) updateData.isRecommended = data.isRecommended + + // 항목 업데이트 + await db.update(procurementQuotationItems) + .set(updateData) + .where(eq(procurementQuotationItems.id, data.id)) + + // 캐시 무효화 + revalidateTag(`quotation-${item.quotationId}`) + + return { + success: true, + message: "견적 항목이 업데이트되었습니다", + } + } catch (error) { + console.error("견적 항목 업데이트 오류:", error) + return { + success: false, + message: "견적 항목 업데이트 중 오류가 발생했습니다", + } + } +} + + +// Quotation 상태 타입 정의 +export type QuotationStatus = "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"; + +// 인터페이스 정의 +export interface Quotation { + id: number; + quotationCode: string; + status: QuotationStatus; + totalPrice: string; + currency: string; + submittedAt: string | null; + validUntil: string | null; + vendorId: number; + rfq?: { + rfqCode: string; + } | null; + vendor?: any; +} + + +/** + * 벤더별 견적서 목록 조회 + */ +export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: string) { + return unstable_cache( + async () => { + try { + // 페이지네이션 설정 + const page = input.page || 1; + const perPage = input.perPage || 10; + const offset = (page - 1) * perPage; + + // 필터링 설정 + // advancedTable 모드로 where 절 구성 + const advancedWhere = filterColumns({ + table: procurementVendorQuotations, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 글로벌 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(procurementVendorQuotations.quotationCode, s), + ilike(procurementVendorQuotations.status, s), + ilike(procurementVendorQuotations.totalPrice, s) + ); + } + + // 벤더 ID 조건 + const vendorIdWhere = vendorId ? + eq(procurementVendorQuotations.vendorId, Number(vendorId)) : + undefined; + + // 모든 조건 결합 + let whereConditions = []; + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (vendorIdWhere) whereConditions.push(vendorIdWhere); + + // 최종 조건 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + // 정렬 설정 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => { + // @ts-ignore - 동적 속성 접근 + return item.desc ? desc(procurementVendorQuotations[item.id]) : asc(procurementVendorQuotations[item.id]); + }) + : [asc(procurementVendorQuotations.updatedAt)]; + + // 쿼리 실행 + const quotations = await db.query.procurementVendorQuotations.findMany({ + where: finalWhere, + orderBy, + offset, + limit: perPage, + with: { + rfq: true, + vendor: true, + } + }); + + // 전체 개수 조회 + const { totalCount } = await db + .select({ totalCount: count() }) + .from(procurementVendorQuotations) + .where(finalWhere || undefined) + .then(rows => rows[0]); + + console.log(totalCount) + + // 페이지 수 계산 + const pageCount = Math.ceil(Number(totalCount) / perPage); + + return { + data: quotations as Quotation[], + pageCount + }; + } catch (err) { + console.error("getVendorQuotations 에러:", err); + return { data: [], pageCount: 0 }; + } + }, + [`vendor-quotations-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: [`vendor-quotations-${vendorId}`], + } + )(); +} + +/** + * 견적서 상태별 개수 조회 + */ +export async function getQuotationStatusCounts(vendorId: string) { + return unstable_cache( + async () => { + try { + const initial: Record<QuotationStatus, number> = { + Draft: 0, + Submitted: 0, + Revised: 0, + Rejected: 0, + Accepted: 0, + }; + + // 벤더 ID 조건 + const whereCondition = vendorId ? + eq(procurementVendorQuotations.vendorId, Number(vendorId)) : + undefined; + + // 상태별 그룹핑 쿼리 + const rows = await db + .select({ + status: procurementVendorQuotations.status, + count: count(), + }) + .from(procurementVendorQuotations) + .where(whereCondition) + .groupBy(procurementVendorQuotations.status); + + // 결과 처리 + const result = rows.reduce<Record<QuotationStatus, number>>((acc, { status, count }) => { + if (status) { + acc[status as QuotationStatus] = Number(count); + } + return acc; + }, initial); + + return result; + } catch (err) { + console.error("getQuotationStatusCounts 에러:", err); + return {} as Record<QuotationStatus, number>; + } + }, + [`quotation-status-counts-${vendorId}`], + { + revalidate: 3600, + } + )(); +} + + +/** + * 벤더 입장에서 구매자와의 커뮤니케이션 메시지를 가져오는 서버 액션 + * + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + * @returns 코멘트 목록 + */ +export async function fetchBuyerVendorComments(rfqId: number, vendorId: number): Promise<Comment[]> { + if (!rfqId || !vendorId) { + return []; + } + + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + throw new Error("인증이 필요합니다"); + } + + // 벤더 접근 권한 확인 (벤더 사용자이며 해당 벤더의 ID와 일치해야 함) + if ( + session.user.domain === "partners" && + ((session.user.companyId ?? 0) !== Number(vendorId)) + ) { + throw new Error("접근 권한이 없습니다"); + } + + // 코멘트 쿼리 + const comments = await db.query.procurementRfqComments.findMany({ + where: and( + eq(procurementRfqComments.rfqId, rfqId), + eq(procurementRfqComments.vendorId, vendorId) + ), + orderBy: [procurementRfqComments.createdAt], + with: { + user: { + columns: { + name: true + } + }, + vendor: { + columns: { + vendorName: true + } + }, + attachments: true, + } + }); + + // 벤더가 접근하는 경우, 벤더 메시지를 읽음 상태로 표시 + if (session.user.domain === "partners") { + // 읽지 않은 구매자 메시지를 읽음 상태로 업데이트 + await db.update(procurementRfqComments) + .set({ isRead: true }) + .where( + and( + eq(procurementRfqComments.rfqId, rfqId), + eq(procurementRfqComments.vendorId, vendorId), + eq(procurementRfqComments.isVendorComment, false), // 구매자가 보낸 메시지 + eq(procurementRfqComments.isRead, false) + ) + ) + .execute(); + } + + // 결과 매핑 + return comments.map(comment => ({ + id: comment.id, + rfqId: comment.rfqId, + vendorId: comment.vendorId, + userId: comment.userId || undefined, + content: comment.content, + isVendorComment: comment.isVendorComment, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + userName: comment.user?.name, + vendorName: comment.vendor?.vendorName, + isRead: comment.isRead, + attachments: comment.attachments.map(att => ({ + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize, + fileType: att.fileType, + filePath: att.filePath, + uploadedAt: att.uploadedAt + })) + })); + } catch (error) { + console.error('벤더-구매자 커뮤니케이션 가져오기 오류:', error); + throw error; + } +} + + +const getRandomProject = async () => { + const allProjects = await db.select().from(projects).limit(10); + const randomIndex = Math.floor(Math.random() * allProjects.length); + return allProjects[randomIndex] || null; +}; + +const getRandomItem = async () => { + const allItems = await db.select().from(items).limit(10); + const randomIndex = Math.floor(Math.random() * allItems.length); + return allItems[randomIndex] || null; +}; + +// 외부 시스템에서 RFQ 가져오기 서버 액션 +export async function fetchExternalRfqs() { + try { + // 현재 로그인한 사용자의 세션 정보 가져오기 + const session = await getServerSession(authOptions); + + if (!session || !session.user || !session.user.id) { + return { + success: false, + message: '인증된 사용자 정보를 찾을 수 없습니다' + }; + } + + const userId = session.user.id; + + const randomProject = await getRandomProject(); + const randomItem = await getRandomItem(); + + if (!randomProject || !randomItem) { + return { + success: false, + message: '임의 데이터를 생성하는 데 필요한 기본 데이터가 없습니다' + }; + } + + // 현재 날짜 기준 임의 날짜 생성 + const today = new Date(); + const dueDate = new Date(today); + dueDate.setDate(today.getDate() + Math.floor(Math.random() * 30) + 15); // 15-45일 후 + + + // RFQ 코드 생성 (현재 연도 + 3자리 숫자) + const currentYear = today.getFullYear(); + const randomNum = Math.floor(Math.random() * 900) + 100; // 100-999 + const rfqCode = `R${currentYear}${randomNum}`; + const seriesOptions = ["SS", "II", ""]; + const randomSeriesIndex = Math.floor(Math.random() * seriesOptions.length); + const seriesValue = seriesOptions[randomSeriesIndex]; + + // RFQ 생성 - 로그인한 사용자 ID 사용 + const newRfq = await db.insert(procurementRfqs).values({ + rfqCode, + projectId: randomProject.id, + series:seriesValue, + itemId: randomItem.id, + dueDate, + rfqSendDate: null, // null로 설정/ + status: "RFQ Created", + rfqSealedYn: false, + picCode: `PIC-${Math.floor(Math.random() * 1000)}`, + remark: "테스트용으로 아무말이나 들어간 것으로 실제로는 SAP에 있는 값이 옵니다. 오해 ㄴㄴ", + createdBy: userId, + updatedBy: userId, + }).returning(); + + if (newRfq.length === 0) { + return { + success: false, + message: 'RFQ 생성에 실패했습니다' + }; + } + + // PR 항목 생성 (1-3개 임의 생성) + const prItemsCount = Math.floor(Math.random() * 3) + 1; + const createdPrItems = []; + + for (let i = 0; i < prItemsCount; i++) { + const deliveryDate = new Date(today); + deliveryDate.setDate(today.getDate() + Math.floor(Math.random() * 60) + 30); // 30-90일 후 + + const randomTwoDigits = String(Math.floor(Math.random() * 100)).padStart(2, '0'); + // 프로젝트와 아이템 코드가 있다고 가정하고, 없을 경우 기본값 사용 + const projectCode = randomProject.code || 'PROJ'; + const itemCode = randomItem.itemCode || 'ITEM'; + const materialCode = `${projectCode}${itemCode}${randomTwoDigits}`; + const isMajor = i === 0 ? true : Math.random() > 0.7; + + const newPrItem = await db.insert(prItems).values({ + procurementRfqsId: newRfq[0].id, + rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`, + prItem: `PRI-${Math.floor(Math.random() * 1000)}`, + prNo: `PRN-${Math.floor(Math.random() * 1000)}`, + itemId: randomItem.id, + materialCode, + materialCategory: "Standard", + acc: `ACC-${Math.floor(Math.random() * 100)}`, + materialDescription: `${['알루미늄', '구리', '철', '실리콘'][Math.floor(Math.random() * 4)]} 재질 부품`, + size: `${Math.floor(Math.random() * 100) + 10}x${Math.floor(Math.random() * 100) + 10}`, + deliveryDate, + quantity: Math.floor(Math.random() * 100) + 1, + uom: ['EA', 'KG', 'M', 'L'][Math.floor(Math.random() * 4)], + grossWeight: Math.floor(Math.random() * 1000) / 10, + gwUom: ['KG', 'T'][Math.floor(Math.random() * 2)], + specNo: `SPEC-${Math.floor(Math.random() * 1000)}`, + majorYn:isMajor, // 30% 확률로 true + remark: "외부 시스템에서 가져온 PR 항목", + }).returning(); + + createdPrItems.push(newPrItem[0]); + } + + revalidateTag(`rfqs-po`) + + return { + success: true, + message: '외부 RFQ를 성공적으로 가져왔습니다', + data: { + rfq: newRfq[0], + prItems: createdPrItems + } + }; + + } catch (error) { + console.error('외부 RFQ 가져오기 오류:', error); + return { + success: false, + message: '외부 RFQ를 가져오는 중 오류가 발생했습니다' + }; + } +} + +/** + * RFQ ID에 해당하는 모든 벤더 견적 정보를 조회하는 서버 액션 + * @param rfqId RFQ ID + * @returns 견적 정보 목록 + */ +export async function fetchVendorQuotations(rfqId: number) { + try { + // 벤더 정보와 함께 견적 정보 조회 + const quotations = await db + .select({ + // 견적 기본 정보 + id: procurementVendorQuotations.id, + rfqId: procurementVendorQuotations.rfqId, + vendorId: procurementVendorQuotations.vendorId, + quotationCode: procurementVendorQuotations.quotationCode, + quotationVersion: procurementVendorQuotations.quotationVersion, + totalItemsCount: procurementVendorQuotations.totalItemsCount, + subTotal: procurementVendorQuotations.subTotal, + taxTotal: procurementVendorQuotations.taxTotal, + discountTotal: procurementVendorQuotations.discountTotal, + totalPrice: procurementVendorQuotations.totalPrice, + currency: procurementVendorQuotations.currency, + validUntil: procurementVendorQuotations.validUntil, + estimatedDeliveryDate: procurementVendorQuotations.estimatedDeliveryDate, + paymentTermsCode: procurementVendorQuotations.paymentTermsCode, + incotermsCode: procurementVendorQuotations.incotermsCode, + incotermsDetail: procurementVendorQuotations.incotermsDetail, + status: procurementVendorQuotations.status, + remark: procurementVendorQuotations.remark, + rejectionReason: procurementVendorQuotations.rejectionReason, + submittedAt: procurementVendorQuotations.submittedAt, + acceptedAt: procurementVendorQuotations.acceptedAt, + createdAt: procurementVendorQuotations.createdAt, + updatedAt: procurementVendorQuotations.updatedAt, + + // 벤더 정보 + vendorName: vendors.vendorName, + paymentTermsDescription: paymentTerms.description, + incotermsDescription: incoterms.description, + }) + .from(procurementVendorQuotations) + .leftJoin(vendors, eq(procurementVendorQuotations.vendorId, vendors.id)) + .leftJoin(paymentTerms, eq(procurementVendorQuotations.paymentTermsCode, paymentTerms.code)) + .leftJoin(incoterms, eq(procurementVendorQuotations.incotermsCode, incoterms.code)) + .where( + and( + eq(procurementVendorQuotations.rfqId, rfqId), + // eq(procurementVendorQuotations.status, "Submitted") // <=== Submitted 상태만! + ) + ) + .orderBy(desc(procurementVendorQuotations.updatedAt)) + + + return { success: true, data: quotations } + } catch (error) { + console.error("벤더 견적 조회 오류:", error) + return { success: false, error: "벤더 견적을 조회하는 중 오류가 발생했습니다" } + } +} + +/** + * 견적 ID 목록에 해당하는 모든 견적 아이템 정보를 조회하는 서버 액션 + * @param quotationIds 견적 ID 배열 + * @returns 견적 아이템 정보 목록 + */ +export async function fetchQuotationItems(quotationIds: number[]) { + try { + // 빈 배열이 전달된 경우 빈 결과 반환 + if (!quotationIds.length) { + return { success: true, data: [] } + } + + // 견적 아이템 정보 조회 + const items = await db + .select() + .from(procurementQuotationItems) + .where(inArray(procurementQuotationItems.quotationId, quotationIds)) + .orderBy(procurementQuotationItems.id) + + return { success: true, data: items } + } catch (error) { + console.error("견적 아이템 조회 오류:", error) + return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" } + } +} |
