summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/service.ts')
-rw-r--r--lib/techsales-rfq/service.ts1043
1 files changed, 562 insertions, 481 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 96d6a3c9..25e1f379 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -5,7 +5,9 @@ import db from "@/db/db";
import {
techSalesRfqs,
techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
techSalesAttachments,
+ techSalesVendorQuotationAttachments,
users,
techSalesRfqComments,
techSalesRfqItems,
@@ -30,6 +32,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { sendEmail } from "../mail/sendEmail";
import { formatDate } from "../utils";
import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
// 정렬 타입 정의
// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
@@ -79,16 +82,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise<
return codes;
}
-/**
- * 기술영업 조선 RFQ 생성 액션
- *
- * 받을 파라미터 (생성시 입력하는 것)
- * 1. RFQ 관련
- * 2. 프로젝트 관련
- * 3. 자재 관련 (자재그룹)
- *
- * 나머지 벤더, 첨부파일 등은 생성 이후 처리
- */
/**
* 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
@@ -309,8 +302,29 @@ export async function getTechSalesVendorQuotationsWithJoin(input: {
limit: input.perPage,
});
+ // 각 견적서의 첨부파일 정보 조회
+ const dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
- return { data, total };
+ return { data: dataWithAttachments, total };
});
const pageCount = Math.ceil(total / input.perPage);
@@ -414,160 +428,6 @@ export async function getTechSalesDashboardWithJoin(input: {
}
}
-
-
-/**
- * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorFromTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
-}) {
- unstable_noStore();
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await db
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- return {
- data: null,
- error: "해당 벤더가 이 RFQ에 존재하지 않습니다."
- };
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- return {
- data: null,
- error: "Draft 상태의 벤더만 삭제할 수 있습니다."
- };
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await db
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .returning();
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidateTag(`vendor-${input.vendorId}-quotations`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP"));
-
- return { data: deletedQuotations[0], error: null };
- } catch (err) {
- console.error("Error removing vendor from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorsFromTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
- try {
- const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
- const errors: string[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
- for (const vendorId of input.vendorIds) {
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
- continue;
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
- continue;
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .returning();
-
- if (deletedQuotations.length > 0) {
- results.push(deletedQuotations[0]);
- }
- } catch (vendorError) {
- console.error(`Error removing vendor ${vendorId}:`, vendorError);
- errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`);
- }
- }
- });
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache2 = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP"));
-
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- } catch (err) {
- console.error("Error removing vendors from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
/**
* 특정 RFQ의 벤더 목록 조회
*/
@@ -716,6 +576,19 @@ export async function sendTechSalesRfqToVendors(input: {
.set(updateData)
.where(eq(techSalesRfqs.id, input.rfqId));
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
// 2. 각 벤더에 대해 이메일 발송 처리
for (const quotation of vendorQuotations) {
if (!quotation.vendorId || !quotation.vendor) continue;
@@ -847,6 +720,12 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
const items = itemsResult.data || [];
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
// 기존 구조와 호환되도록 데이터 재구성
const formattedQuotation = {
id: quotation.id,
@@ -911,7 +790,16 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
country: quotation.vendorCountry,
email: quotation.vendorEmail,
phone: quotation.vendorPhone,
- }
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
};
return { data: formattedQuotation, error: null };
@@ -922,7 +810,8 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 벤더 견적서 업데이트 (임시저장)
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
*/
export async function updateTechSalesVendorQuotation(data: {
id: number
@@ -931,46 +820,78 @@ export async function updateTechSalesVendorQuotation(data: {
validUntil: Date
remark?: string
updatedBy: number
+ changeReason?: string
}) {
try {
- // 현재 견적서 상태 및 벤더 ID 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
- // Draft 또는 Revised 상태에서만 수정 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." };
- }
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
- return { data: result[0], error: null }
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error updating tech sales vendor quotation:", error)
- return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
}
}
@@ -983,63 +904,134 @@ export async function submitTechSalesVendorQuotation(data: {
totalPrice: string
validUntil: Date
remark?: string
+ attachments?: Array<{
+ fileName: string
+ filePath: string
+ fileSize: number
+ }>
updatedBy: number
}) {
try {
- // 현재 견적서 상태 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 항상 revision 저장 (변경사항 여부와 관계없이)
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
- // Draft 또는 Revised 상태에서만 제출 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." };
- }
+ // 새로운 버전 번호 계산 (항상 1 증가)
+ const newRevisionId = (currentQuotation.quotationVersion || 1) + 1;
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- status: "Submitted",
- submittedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
- // 메일 발송 (백그라운드에서 실행)
- if (result[0]) {
- // 벤더에게 견적 제출 확인 메일 발송
- sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
- console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
- });
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName,
+ originalFileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.fileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
- // 담당자에게 견적 접수 알림 메일 발송
- sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
- console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
- });
- }
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`)
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
- return { data: result[0], error: null }
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error submitting tech sales vendor quotation:", error)
- return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
}
}
@@ -1095,14 +1087,17 @@ export async function getVendorQuotations(input: {
const offset = (page - 1) * perPage;
const limit = perPage;
- // 기본 조건: 해당 벤더의 견적서만 조회
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
const vendorIdNum = parseInt(vendorId);
if (isNaN(vendorIdNum)) {
console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
return { data: [], pageCount: 0, total: 0 };
}
- const baseConditions = [eq(techSalesVendorQuotations.vendorId, vendorIdNum)];
+ const baseConditions = [
+ eq(techSalesVendorQuotations.vendorId, vendorIdNum),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
// rfqType 필터링 추가
if (input.rfqType) {
@@ -1210,9 +1205,13 @@ export async function getVendorQuotations(input: {
description: techSalesRfqs.description,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
- // 아이템 정보 추가 (임시로 description 사용)
- // itemName: techSalesRfqs.description,
- // 첨부파일 개수
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수
attachmentCount: sql<number>`(
SELECT COUNT(*)
FROM tech_sales_attachments
@@ -1221,6 +1220,7 @@ export async function getVendorQuotations(input: {
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(techSalesAttachments, eq(techSalesRfqs.id, techSalesAttachments.techSalesRfqId))
.leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
.where(finalWhere)
.orderBy(...orderBy)
@@ -1256,48 +1256,6 @@ export async function getVendorQuotations(input: {
}
/**
- * 벤더용 기술영업 견적서 상태별 개수 조회
- */
-export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") {
- return unstable_cache(
- async () => {
- try {
- const query = db
- .select({
- status: techSalesVendorQuotations.status,
- count: sql<number>`count(*)`,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id));
-
- // 조건 설정
- const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))];
- if (rfqType) {
- conditions.push(eq(techSalesRfqs.rfqType, rfqType));
- }
-
- const result = await query
- .where(and(...conditions))
- .groupBy(techSalesVendorQuotations.status);
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error fetching quotation status counts:", err);
- return { data: null, error: getErrorMessage(err) };
- }
- },
- [vendorId], // 캐싱 키
- {
- revalidate: 60, // 1분간 캐시
- tags: [
- "techSalesVendorQuotations",
- `vendor-${vendorId}-quotations`
- ],
- }
- )();
-}
-
-/**
* 기술영업 벤더 견적 승인 (벤더 선택)
*/
export async function acceptTechSalesVendorQuotation(quotationId: number) {
@@ -1358,6 +1316,9 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
for (const vendorQuotation of allVendorsInRfq) {
revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
}
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
return { success: true, data: result }
} catch (error) {
@@ -1370,7 +1331,7 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 RFQ 첨부파일 생성 (파일 업로드)
+ * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x
*/
export async function createTechSalesRfqAttachments(params: {
techSalesRfqId: number
@@ -1415,8 +1376,7 @@ export async function createTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const file of files) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
@@ -1424,7 +1384,7 @@ export async function createTechSalesRfqAttachments(params: {
const absolutePath = path.join(process.cwd(), "public", relativePath);
// 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -1488,6 +1448,39 @@ export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
}
/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
* 기술영업 RFQ 첨부파일 삭제
*/
export async function deleteTechSalesRfqAttachment(attachmentId: number) {
@@ -1561,7 +1554,7 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) {
*/
export async function processTechSalesRfqAttachments(params: {
techSalesRfqId: number
- newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[]
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
deleteAttachmentIds: number[]
createdBy: number
}) {
@@ -1623,16 +1616,16 @@ export async function processTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const { file, attachmentType, description } of newFiles) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ // 파일 복호화
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName);
const absolutePath = path.join(process.cwd(), "public", relativePath);
- // 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ // 복호화된 파일 저장
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -2213,6 +2206,8 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb
}
}
+// ==================== RFQ 조선/해양 관련 ====================
+
/**
* 기술영업 조선 RFQ 생성 (1:N 관계)
*/
@@ -2223,9 +2218,7 @@ export async function createTechSalesShipRfq(input: {
description?: string;
createdBy: number;
}) {
- unstable_noStore();
- console.log('🔍 createTechSalesShipRfq 호출됨:', input);
-
+ unstable_noStore();
try {
return await db.transaction(async (tx) => {
// 프로젝트 정보 조회 (유효성 검증)
@@ -2474,46 +2467,7 @@ export async function getTechSalesHullVendorQuotationsWithJoin(input: {
return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
}
-/**
- * 조선 대시보드 전용 조회 함수
- */
-export async function getTechSalesShipDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP 대시보드 전용 조회 함수
- */
-export async function getTechSalesTopDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL 대시보드 전용 조회 함수
- */
-export async function getTechSalesHullDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
+/**
* 기술영업 RFQ의 아이템 목록 조회
*/
export async function getTechSalesRfqItems(rfqId: number) {
@@ -2700,53 +2654,6 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) {
}
/**
- * 기술영업 RFQ에 벤더 추가 (techVendors 기반)
- */
-export async function addTechVendorToTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
- createdBy: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 벤더가 이미 추가되어 있는지 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- });
-
- if (existingQuotation) {
- return { data: null, error: "이미 추가된 벤더입니다." };
- }
-
- // 새로운 견적서 레코드 생성
- const [quotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: input.vendorId,
- status: "Draft",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesVendorQuotations.id });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
-
- return { data: quotation, error: null };
- });
- } catch (err) {
- console.error("Error adding tech vendor to RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
* RFQ 타입에 따른 캐시 무효화 경로 반환
*/
function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
@@ -2764,6 +2671,7 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string
/**
* 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
*/
export async function addTechVendorsToTechSalesRfq(input: {
rfqId: number;
@@ -2783,7 +2691,7 @@ export async function addTechVendorsToTechSalesRfq(input: {
columns: {
id: true,
status: true,
- rfqType: true
+ rfqType: true,
}
});
@@ -2791,10 +2699,10 @@ export async function addTechVendorsToTechSalesRfq(input: {
throw new Error("RFQ를 찾을 수 없습니다");
}
- // 2. 각 벤더에 대해 처리
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
for (const vendorId of input.vendorIds) {
try {
- // 벤더가 이미 추가되어 있는지 확인
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
where: and(
eq(techSalesVendorQuotations.rfqId, input.rfqId),
@@ -2807,19 +2715,30 @@ export async function addTechVendorsToTechSalesRfq(input: {
continue;
}
- // 새로운 견적서 레코드 생성
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
const [quotation] = await tx
.insert(techSalesVendorQuotations)
.values({
rfqId: input.rfqId,
vendorId: vendorId,
- status: "Draft",
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
createdBy: input.createdBy,
updatedBy: input.createdBy,
})
.returning({ id: techSalesVendorQuotations.id });
-
- results.push(quotation);
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
} catch (vendorError) {
console.error(`Error adding vendor ${vendorId}:`, vendorError);
errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
@@ -2843,11 +2762,6 @@ export async function addTechVendorsToTechSalesRfq(input: {
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
return {
data: results,
error: errors.length > 0 ? errors.join(", ") : null,
@@ -2921,9 +2835,9 @@ export async function removeTechVendorFromTechSalesRfq(input: {
return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." };
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
}
// 해당 벤더의 견적서 삭제
@@ -2977,9 +2891,9 @@ export async function removeTechVendorsFromTechSalesRfq(input: {
continue;
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
continue;
}
@@ -3060,6 +2974,242 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType
}
}
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
/**
* Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
*/
@@ -3076,9 +3226,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
try {
const offset = (input.page - 1) * input.perPage;
- // 기본 WHERE 조건: status = 'Accepted'만 조회
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted')
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
];
// 검색 조건 추가
@@ -3126,10 +3277,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
// 필터 조건 추가
const filterConditions = [];
if (input.filters?.length) {
- const { filterWhere, joinOperator } = filterColumns({
+ const filterWhere = filterColumns({
table: techSalesVendorQuotations,
filters: input.filters,
- joinOperator: input.joinOperator ?? "and",
+ joinOperator: "and",
});
if (filterWhere) {
filterConditions.push(filterWhere);
@@ -3221,74 +3372,4 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
}
-}
-
-/**
- * 벤더 견적서 거절 처리 (벤더가 직접 거절)
- */
-export async function rejectTechSalesVendorQuotations(input: {
- quotationIds: number[];
- rejectionReason?: string;
-}) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.");
- }
-
- const result = await db.transaction(async (tx) => {
- // 견적서들이 존재하고 벤더가 권한이 있는지 확인
- const quotations = await tx
- .select({
- id: techSalesVendorQuotations.id,
- status: techSalesVendorQuotations.status,
- vendorId: techSalesVendorQuotations.vendorId,
- })
- .from(techSalesVendorQuotations)
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- if (quotations.length !== input.quotationIds.length) {
- throw new Error("일부 견적서를 찾을 수 없습니다.");
- }
-
- // 이미 거절된 견적서가 있는지 확인
- const alreadyRejected = quotations.filter(q => q.status === "Rejected");
- if (alreadyRejected.length > 0) {
- throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
- }
-
- // 승인된 견적서가 있는지 확인
- const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
- if (alreadyAccepted.length > 0) {
- throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
- }
-
- // 견적서 상태를 거절로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Rejected",
- rejectionReason: input.rejectionReason || null,
- updatedBy: parseInt(session.user.id),
- updatedAt: new Date(),
- })
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- return { success: true, updatedCount: quotations.length };
- });
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidatePath("/partners/techsales/rfq-ship", "page");
- return {
- success: true,
- message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
- data: result
- };
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- return {
- success: false,
- error: getErrorMessage(error)
- };
- }
} \ No newline at end of file