summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/services.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/services.ts')
-rw-r--r--lib/procurement-rfqs/services.ts2055
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: "견적 아이템을 조회하는 중 오류가 발생했습니다" }
+ }
+}