summaryrefslogtreecommitdiff
path: root/lib/rfq-last/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
commit675b4e3d8ffcb57a041db285417d81e61284d900 (patch)
tree254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/service.ts
parent39f12cb19f29cbc5568057e154e6adf4789ae736 (diff)
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/service.ts')
-rw-r--r--lib/rfq-last/service.ts1865
1 files changed, 1497 insertions, 368 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index ac7104df..7470428f 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -3,12 +3,20 @@
import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema";
+import { paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema";
import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { sendEmail } from "../mail/sendEmail";
+import fs from 'fs/promises'
+import path from 'path'
+import { addDays, format } from "date-fns"
+import { ko, enUS } from "date-fns/locale"
+import { generateBasicContractsForVendor } from "../basic-contract/gen-service";
+import { writeFile, mkdir } from "fs/promises";
+
export async function getRfqs(input: GetRfqsSchema) {
unstable_noStore();
@@ -141,6 +149,9 @@ export async function getRfqs(input: GetRfqsSchema) {
}
}
+const isDevelopment = process.env.NODE_ENV === 'development' ||
+ process.env.NODE_ENV === 'test';
+
const getRfqById = async (id: number): Promise<RfqsLastView | null> => {
// 1) RFQ 단건 조회
const rfqsRes = await db
@@ -215,15 +226,15 @@ export async function getRfqAllAttachments(rfqId: number) {
.where(eq(rfqLastAttachments.rfqId, rfqId))
.orderBy(desc(rfqLastAttachments.createdAt))
- return {
- data,
- success: true
+ return {
+ data,
+ success: true
}
} catch (err) {
console.error("getRfqAllAttachments error:", err)
- return {
- data: [],
- success: false
+ return {
+ data: [],
+ success: false
}
}
}
@@ -740,7 +751,7 @@ export async function getRevisionHistory(attachmentId: number): Promise<{
})
.from(rfqLastAttachmentRevisions)
.where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId));
-
+
originalFileName = latestRevision?.originalFileName || null;
}
@@ -834,33 +845,33 @@ export async function getRfqVendorAttachments(rfqId: number) {
vendorResponseId: rfqLastVendorAttachments.vendorResponseId,
attachmentType: rfqLastVendorAttachments.attachmentType,
documentNo: rfqLastVendorAttachments.documentNo,
-
+
// 파일 정보
fileName: rfqLastVendorAttachments.fileName,
originalFileName: rfqLastVendorAttachments.originalFileName,
filePath: rfqLastVendorAttachments.filePath,
fileSize: rfqLastVendorAttachments.fileSize,
fileType: rfqLastVendorAttachments.fileType,
-
+
// 파일 설명
description: rfqLastVendorAttachments.description,
-
+
// 유효기간
validFrom: rfqLastVendorAttachments.validFrom,
validTo: rfqLastVendorAttachments.validTo,
-
+
// 업로드 정보
uploadedBy: rfqLastVendorAttachments.uploadedBy,
uploadedAt: rfqLastVendorAttachments.uploadedAt,
-
+
// 업로더 정보
uploadedByName: users.name,
-
+
// 벤더 정보
vendorId: rfqLastVendorResponses.vendorId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
-
+
// 응답 상태
responseStatus: rfqLastVendorResponses.status,
responseVersion: rfqLastVendorResponses.responseVersion,
@@ -876,7 +887,7 @@ export async function getRfqVendorAttachments(rfqId: number) {
.orderBy(desc(rfqLastVendorAttachments.uploadedAt))
return {
- vendorData,
+ vendorData: data,
vendorSuccess: true
}
} catch (err) {
@@ -994,7 +1005,7 @@ export async function addVendorToRfq({
});
revalidatePath(`/rfq-last/${rfqId}/vendor`);
-
+
return { success: true };
} catch (error) {
console.error("Add vendor error:", error);
@@ -1006,6 +1017,7 @@ export async function addVendorsToRfq({
rfqId,
vendorIds,
conditions,
+ contractRequirements, // 추가된 파라미터
}: {
rfqId: number;
vendorIds: number[];
@@ -1025,21 +1037,26 @@ export async function addVendorsToRfq({
firstDescription?: string;
sparepartDescription?: string;
} | null;
+ contractRequirements?: { // 추가된 타입 정의
+ agreementYn?: boolean;
+ ndaYn?: boolean;
+ gtcType?: "general" | "project" | "none";
+ } | null;
}) {
try {
- const session = await getServerSession(authOptions)
-
+ const session = await getServerSession(authOptions);
+
if (!session?.user) {
- throw new Error("인증이 필요합니다.")
+ throw new Error("인증이 필요합니다.");
}
-
- const userId = Number(session.user.id)
-
+
+ const userId = Number(session.user.id);
+
// 빈 배열 체크
if (!vendorIds || vendorIds.length === 0) {
return { success: false, error: "벤더를 선택해주세요." };
}
-
+
// 중복 체크 - 이미 추가된 벤더들 확인
const existingVendors = await db
.select({
@@ -1052,25 +1069,40 @@ export async function addVendorsToRfq({
inArray(rfqLastDetails.vendorsId, vendorIds)
)
);
-
+
const existingVendorIds = existingVendors.map(v => v.vendorId);
const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id));
-
+
if (newVendorIds.length === 0) {
- return {
- success: false,
- error: "모든 벤더가 이미 추가되어 있습니다."
+ return {
+ success: false,
+ error: "모든 벤더가 이미 추가되어 있습니다."
};
}
-
+
// 일부만 중복인 경우 경고 메시지 준비
const skippedCount = vendorIds.length - newVendorIds.length;
-
+
// 트랜잭션으로 처리
const results = await db.transaction(async (tx) => {
const addedVendors = [];
-
+
for (const vendorId of newVendorIds) {
+ // 벤더 정보 조회 (국가 정보 확인용)
+ const [vendor] = await tx
+ .select({
+ id: vendors.id,
+ country: vendors.country,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1);
+
+ // 국외 업체인지 확인
+ const isInternational = vendor?.country &&
+ vendor.country !== "KR" &&
+ vendor.country !== "한국";
+
// conditions가 없는 경우 기본값 설정
const vendorConditions = conditions || {
currency: "USD",
@@ -1079,94 +1111,79 @@ export async function addVendorsToRfq({
deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
taxCode: "VV",
};
-
- // 1. rfqLastDetails에 벤더 추가
+
+ // contractRequirements 기본값 설정
+ const defaultContractRequirements = {
+ agreementYn: true,
+ ndaYn: true,
+ gtcType: "none" as "general" | "project" | "none",
+ };
+
+ const finalContractRequirements = contractRequirements || defaultContractRequirements;
+
+ // gtcType에 따라 generalGtcYn과 projectGtcYn 설정
+ const generalGtcYn = isInternational && finalContractRequirements.gtcType === "general";
+ const projectGtcYn = isInternational && finalContractRequirements.gtcType === "project";
+
+ // 국내 업체는 gtcType을 강제로 "none"으로 설정
+ const gtcType = isInternational ? finalContractRequirements.gtcType : "none";
+
+ // 1. rfqLastDetails에 벤더 추가 (기본계약 정보 포함)
const [detail] = await tx
.insert(rfqLastDetails)
.values({
rfqsLastId: rfqId,
vendorsId: vendorId,
...vendorConditions,
+ // 기본계약 관련 필드 추가
+ agreementYn: finalContractRequirements.agreementYn ?? true,
+ ndaYn: finalContractRequirements.ndaYn ?? true,
+ gtcType: gtcType,
+ generalGtcYn: generalGtcYn,
+ projectGtcYn: projectGtcYn,
updatedBy: userId,
+ updatedAt: new Date(),
})
.returning();
-
- // 2. rfqLastVendorResponses에 초기 응답 레코드 생성
- const [response] = await tx
- .insert(rfqLastVendorResponses)
- .values({
- rfqsLastId: rfqId,
- rfqLastDetailsId: detail.id,
- vendorId: vendorId,
- status: "초대됨",
- responseVersion: 1,
- isLatest: true,
- currency: vendorConditions.currency,
- // 구매자 제시 조건 복사 (초기값)
- vendorCurrency: vendorConditions.currency,
- vendorPaymentTermsCode: vendorConditions.paymentTermsCode,
- vendorIncotermsCode: vendorConditions.incotermsCode,
- vendorIncotermsDetail: vendorConditions.incotermsDetail,
- vendorDeliveryDate: vendorConditions.deliveryDate,
- vendorContractDuration: vendorConditions.contractDuration,
- vendorTaxCode: vendorConditions.taxCode,
- vendorPlaceOfShipping: vendorConditions.placeOfShipping,
- vendorPlaceOfDestination: vendorConditions.placeOfDestination,
- vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn,
- vendorSparepartYn: vendorConditions.sparepartYn,
- vendorFirstYn: vendorConditions.firstYn,
- vendorFirstDescription: vendorConditions.firstDescription,
- vendorSparepartDescription: vendorConditions.sparepartDescription,
- createdBy: userId,
- updatedBy: userId,
- })
- .returning();
-
- // 3. 이력 기록
- await tx.insert(rfqLastVendorResponseHistory).values({
- vendorResponseId: response.id,
- action: "생성",
- newStatus: "초대됨",
- changeDetails: {
- action: "벤더 초대",
- conditions: vendorConditions,
- batchAdd: true,
- totalVendors: newVendorIds.length
- },
- performedBy: userId,
- });
-
+
addedVendors.push({
vendorId,
detailId: detail.id,
- responseId: response.id,
+ contractRequirements: {
+ agreementYn: detail.agreementYn,
+ ndaYn: detail.ndaYn,
+ gtcType: detail.gtcType,
+ generalGtcYn: detail.generalGtcYn,
+ projectGtcYn: detail.projectGtcYn,
+ }
});
}
-
+
return addedVendors;
});
-
- revalidatePath(`/rfq-last/${rfqId}/vendor`);
-
+
+ revalidatePath(`/evcp/rfq-last/${rfqId}/vendor`);
+
// 성공 메시지 구성
let message = `${results.length}개 벤더가 추가되었습니다.`;
if (skippedCount > 0) {
message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`;
}
-
- return {
+
+ return {
success: true,
data: {
added: results.length,
skipped: skippedCount,
message,
+ vendors: results, // 추가된 벤더 정보 반환
}
};
} catch (error) {
console.error("Add vendors error:", error);
- return {
- success: false,
- error: "벤더 추가 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: "벤더 추가 중 오류가 발생했습니다."
};
}
}
@@ -1198,17 +1215,17 @@ export async function updateVendorConditionsBatch({
}) {
try {
const session = await getServerSession(authOptions)
-
+
if (!session?.user) {
throw new Error("인증이 필요합니다.")
}
-
+
const userId = Number(session.user.id)
-
+
if (!vendorIds || vendorIds.length === 0) {
return { success: false, error: "벤더를 선택해주세요." };
}
-
+
// 트랜잭션으로 처리
await db.transaction(async (tx) => {
// 1. rfqLastDetails 업데이트
@@ -1225,7 +1242,7 @@ export async function updateVendorConditionsBatch({
inArray(rfqLastDetails.vendorsId, vendorIds)
)
);
-
+
// 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트
const vendorConditions = Object.keys(conditions).reduce((acc, key) => {
if (conditions[key] !== undefined) {
@@ -1233,7 +1250,7 @@ export async function updateVendorConditionsBatch({
}
return acc;
}, {});
-
+
await tx
.update(rfqLastVendorResponses)
.set({
@@ -1248,7 +1265,7 @@ export async function updateVendorConditionsBatch({
eq(rfqLastVendorResponses.isLatest, true)
)
);
-
+
// 3. 이력 기록 (각 벤더별로)
const responses = await tx
.select({ id: rfqLastVendorResponses.id })
@@ -1260,25 +1277,25 @@ export async function updateVendorConditionsBatch({
eq(rfqLastVendorResponses.isLatest, true)
)
);
-
+
for (const response of responses) {
await tx.insert(rfqLastVendorResponseHistory).values({
vendorResponseId: response.id,
action: "조건변경",
- changeDetails: {
- action: "조건 일괄 업데이트",
+ changeDetails: {
+ action: "조건 일괄 업데이트",
conditions,
batchUpdate: true,
- totalVendors: vendorIds.length
+ totalVendors: vendorIds.length
},
performedBy: userId,
});
}
});
-
+
revalidatePath(`/rfq-last/${rfqId}/vendor`);
-
- return {
+
+ return {
success: true,
data: {
message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.`
@@ -1286,77 +1303,13 @@ export async function updateVendorConditionsBatch({
};
} catch (error) {
console.error("Update vendor conditions error:", error);
- return {
- success: false,
- error: "조건 업데이트 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: "조건 업데이트 중 오류가 발생했습니다."
};
}
}
-// RFQ 발송 액션
-export async function sendRfqToVendors({
- rfqId,
- vendorIds,
-}: {
- rfqId: number;
- vendorIds: number[];
-}) {
- try {
-
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다.")
- }
- const userId = Number(session.user.id)
-
- // 벤더별 응답 상태 업데이트
- for (const vendorId of vendorIds) {
- const [response] = await db
- .select()
- .from(rfqLastVendorResponses)
- .where(
- and(
- eq(rfqLastVendorResponses.rfqsLastId, rfqId),
- eq(rfqLastVendorResponses.vendorId, vendorId),
- eq(rfqLastVendorResponses.isLatest, true)
- )
- )
- .limit(1);
-
- if (response) {
- // 상태 업데이트
- await db
- .update(rfqLastVendorResponses)
- .set({
- status: "작성중",
- updatedBy: userId,
- updatedAt: new Date(),
- })
- .where(eq(rfqLastVendorResponses.id, response.id));
-
- // 이력 기록
- await db.insert(rfqLastVendorResponseHistory).values({
- vendorResponseId: response.id,
- action: "발송",
- previousStatus: response.status,
- newStatus: "작성중",
- changeDetails: { action: "RFQ 발송" },
- performedBy: userId,
- });
- }
- }
-
- // TODO: 실제 이메일 발송 로직
-
- revalidatePath(`/rfq-last/${rfqId}/vendor`);
-
- return { success: true, count: vendorIds.length };
- } catch (error) {
- console.error("Send RFQ error:", error);
- return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." };
- }
-}
// 벤더 삭제 액션
export async function removeVendorFromRfq({
@@ -1387,9 +1340,9 @@ export async function removeVendorFromRfq({
.limit(1);
if (response && response.status !== "초대됨") {
- return {
- success: false,
- error: "이미 진행 중인 벤더는 삭제할 수 없습니다."
+ return {
+ success: false,
+ error: "이미 진행 중인 벤더는 삭제할 수 없습니다."
};
}
@@ -1404,7 +1357,7 @@ export async function removeVendorFromRfq({
);
revalidatePath(`/rfq-last/${rfqId}/vendor`);
-
+
return { success: true };
} catch (error) {
console.error("Remove vendor error:", error);
@@ -1462,7 +1415,7 @@ export async function updateVendorResponseStatus({
});
revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`);
-
+
return { success: true };
} catch (error) {
console.error("Update status error:", error);
@@ -1488,20 +1441,19 @@ export async function getRfqVendorResponses(rfqId: number) {
.select({
id: rfqsLast.id,
rfqCode: rfqsLast.rfqCode,
- title: rfqsLast.title,
+ title: rfqsLast.rfqTitle,
status: rfqsLast.status,
- startDate: rfqsLast.startDate,
- endDate: rfqsLast.endDate,
+ endDate: rfqsLast.dueDate,
})
.from(rfqsLast)
.where(eq(rfqsLast.id, rfqId))
.limit(1);
if (!rfqData || rfqData.length === 0) {
- return {
- success: false,
+ return {
+ success: false,
error: "RFQ를 찾을 수 없습니다.",
- data: null
+ data: null
};
}
@@ -1510,7 +1462,7 @@ export async function getRfqVendorResponses(rfqId: number) {
.select()
.from(rfqLastDetails)
.where(eq(rfqLastDetails.rfqsLastId, rfqId))
- .orderBy(desc(rfqLastDetails.version));
+ .orderBy(desc(rfqLastDetails.updatedAt));
// 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함)
const vendorResponsesData = await db
@@ -1522,39 +1474,61 @@ export async function getRfqVendorResponses(rfqId: number) {
responseVersion: rfqLastVendorResponses.responseVersion,
isLatest: rfqLastVendorResponses.isLatest,
status: rfqLastVendorResponses.status,
-
+
+ //참여 정보
+ participationStatus: rfqLastVendorResponses.participationStatus,
+ participationRepliedAt: rfqLastVendorResponses.participationRepliedAt,
+ participationRepliedBy: rfqLastVendorResponses.participationRepliedBy,
+ nonParticipationReason: rfqLastVendorResponses.nonParticipationReason,
+
// 벤더 정보
vendorId: rfqLastVendorResponses.vendorId,
vendorCode: vendors.vendorCode,
vendorName: vendors.vendorName,
vendorEmail: vendors.email,
-
+
// 제출 정보
submittedAt: rfqLastVendorResponses.submittedAt,
submittedBy: rfqLastVendorResponses.submittedBy,
submittedByName: users.name,
-
+
// 금액 정보
totalAmount: rfqLastVendorResponses.totalAmount,
currency: rfqLastVendorResponses.currency,
-
+
// 벤더 제안 조건
vendorCurrency: rfqLastVendorResponses.vendorCurrency,
vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode,
vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode,
+ vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail,
vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate,
vendorContractDuration: rfqLastVendorResponses.vendorContractDuration,
-
- // 초도품/Spare part 응답
+ vendorTaxCode: rfqLastVendorResponses.vendorTaxCode,
+ vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping,
+ vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination,
+
+ // 초도품/Spare part/연동제 응답
vendorFirstYn: rfqLastVendorResponses.vendorFirstYn,
+ vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription,
vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance,
vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn,
+ vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription,
vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance,
-
+ vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn,
+ vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason,
+
+ // 변경 사유
+ currencyReason: rfqLastVendorResponses.currencyReason,
+ paymentTermsReason: rfqLastVendorResponses.paymentTermsReason,
+ deliveryDateReason: rfqLastVendorResponses.deliveryDateReason,
+ incotermsReason: rfqLastVendorResponses.incotermsReason,
+ taxReason: rfqLastVendorResponses.taxReason,
+ shippingReason: rfqLastVendorResponses.shippingReason,
+
// 비고
generalRemark: rfqLastVendorResponses.generalRemark,
technicalProposal: rfqLastVendorResponses.technicalProposal,
-
+
// 타임스탬프
createdAt: rfqLastVendorResponses.createdAt,
updatedAt: rfqLastVendorResponses.updatedAt,
@@ -1570,108 +1544,269 @@ export async function getRfqVendorResponses(rfqId: number) {
)
.orderBy(desc(rfqLastVendorResponses.createdAt));
- if (!vendorResponsesData || vendorResponsesData.length === 0) {
- return {
- success: true,
- data: [],
- rfq: rfqData[0],
- details: details,
- };
- }
+ if (!vendorResponsesData || vendorResponsesData.length === 0) {
+ return {
+ success: true,
+ data: [],
+ rfq: rfqData[0],
+ details: details,
+ };
+ }
- // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산
- const vendorResponsesWithCounts = await Promise.all(
+ // 4. 각 벤더별 총 응답 수 조회 (모든 버전 포함)
+ const vendorResponseCounts = await db
+ .select({
+ vendorId: rfqLastVendorResponses.vendorId,
+ responseCount: count(),
+ })
+ .from(rfqLastVendorResponses)
+ .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId))
+ .groupBy(rfqLastVendorResponses.vendorId);
+
+ // vendorId를 키로 하는 Map 생성
+ const responseCountMap = new Map(
+ vendorResponseCounts.map(item => [item.vendorId, item.responseCount])
+ );
+
+ // 5. 각 벤더 응답별 상세 정보 조회 (견적 아이템, 첨부파일)
+ const vendorResponsesWithDetails = await Promise.all(
vendorResponsesData.map(async (response) => {
- // 견적 아이템 수 조회
- const itemCount = await db
- .select({ count: count()})
+ // 견적 아이템 상세 조회
+ const quotationItems = await db
+ .select({
+ id: rfqLastVendorQuotationItems.id,
+ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId,
+ rfqPrItemId: rfqLastVendorQuotationItems.rfqPrItemId,
+
+ // PR 아이템 정보
+ prNo: rfqLastVendorQuotationItems.prNo,
+ materialCode: rfqLastVendorQuotationItems.materialCode,
+ materialDescription: rfqLastVendorQuotationItems.materialDescription,
+
+ // 견적 정보
+ quantity: rfqLastVendorQuotationItems.quantity,
+ uom: rfqLastVendorQuotationItems.uom,
+ unitPrice: rfqLastVendorQuotationItems.unitPrice,
+ totalPrice: rfqLastVendorQuotationItems.totalPrice,
+ currency: rfqLastVendorQuotationItems.currency,
+
+ // 납기 정보
+ vendorDeliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate,
+ leadTime: rfqLastVendorQuotationItems.leadTime,
+
+ // 제조사 정보
+ manufacturer: rfqLastVendorQuotationItems.manufacturer,
+ manufacturerCountry: rfqLastVendorQuotationItems.manufacturerCountry,
+ modelNo: rfqLastVendorQuotationItems.modelNo,
+
+ // 기술 사양
+ technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance,
+ alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal,
+
+ // 할인 정보
+ discountRate: rfqLastVendorQuotationItems.discountRate,
+ discountAmount: rfqLastVendorQuotationItems.discountAmount,
+
+ // 비고
+ itemRemark: rfqLastVendorQuotationItems.itemRemark,
+ deviationReason: rfqLastVendorQuotationItems.deviationReason,
+
+ createdAt: rfqLastVendorQuotationItems.createdAt,
+ updatedAt: rfqLastVendorQuotationItems.updatedAt,
+ })
.from(rfqLastVendorQuotationItems)
- .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id));
-
- // 첨부파일 수 조회
- const attachmentCount = await db
- .select({ count: count()})
+ .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id))
+ .orderBy(rfqLastVendorQuotationItems.id);
+
+ // 첨부파일 조회
+ const attachments = await db
+ .select({
+ id: rfqLastVendorAttachments.id,
+ vendorResponseId: rfqLastVendorAttachments.vendorResponseId,
+
+ // 첨부파일 구분
+ attachmentType: rfqLastVendorAttachments.attachmentType,
+ documentNo: rfqLastVendorAttachments.documentNo,
+
+ // 파일 정보
+ fileName: rfqLastVendorAttachments.fileName,
+ originalFileName: rfqLastVendorAttachments.originalFileName,
+ filePath: rfqLastVendorAttachments.filePath,
+ fileSize: rfqLastVendorAttachments.fileSize,
+ fileType: rfqLastVendorAttachments.fileType,
+
+ // 파일 설명
+ description: rfqLastVendorAttachments.description,
+
+ // 유효기간 (인증서 등)
+ validFrom: rfqLastVendorAttachments.validFrom,
+ validTo: rfqLastVendorAttachments.validTo,
+
+ // 업로드 정보
+ uploadedBy: rfqLastVendorAttachments.uploadedBy,
+ uploadedByName: users.name,
+ uploadedAt: rfqLastVendorAttachments.uploadedAt,
+ })
.from(rfqLastVendorAttachments)
- .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id));
+ .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id))
+ .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id))
+ .orderBy(rfqLastVendorAttachments.attachmentType, rfqLastVendorAttachments.uploadedAt);
+
+ // 해당 벤더의 총 응답 수 가져오기
+ const vendorResponseCount = responseCountMap.get(response.vendorId) || 0;
return {
...response,
- quotedItemCount: itemCount[0]?.count || 0,
- attachmentCount: attachmentCount[0]?.count || 0,
+ quotationItems,
+ attachments,
+ vendorResponseCount,
};
})
);
- // 5. 응답 데이터 정리
- const formattedResponses = vendorResponsesWithCounts
- .filter(response => response && response.id).map(response => ({
- id: response.id,
- rfqsLastId: response.rfqsLastId,
- rfqLastDetailsId: response.rfqLastDetailsId,
- responseVersion: response.responseVersion,
- isLatest: response.isLatest,
- status: response.status || "초대됨", // 기본값 설정
-
- // 벤더 정보
- vendor: {
- id: response.vendorId,
- code: response.vendorCode,
- name: response.vendorName,
- email: response.vendorEmail,
- },
-
- // 제출 정보
- submission: {
- submittedAt: response.submittedAt,
- submittedBy: response.submittedBy,
- submittedByName: response.submittedByName,
- },
-
- // 금액 정보
- pricing: {
- totalAmount: response.totalAmount,
- currency: response.currency || "USD",
- vendorCurrency: response.vendorCurrency,
- },
-
- // 벤더 제안 조건
- vendorTerms: {
- paymentTermsCode: response.vendorPaymentTermsCode,
- incotermsCode: response.vendorIncotermsCode,
- deliveryDate: response.vendorDeliveryDate,
- contractDuration: response.vendorContractDuration,
- },
-
- // 초도품/Spare part
- additionalRequirements: {
- firstArticle: {
- required: response.vendorFirstYn,
- acceptance: response.vendorFirstAcceptance,
+ // 6. 응답 데이터 정리
+ const formattedResponses = vendorResponsesWithDetails
+ .filter(response => response && response.id)
+ .map(response => ({
+ id: response.id,
+ rfqsLastId: response.rfqsLastId,
+ rfqLastDetailsId: response.rfqLastDetailsId,
+ responseVersion: response.responseVersion,
+ isLatest: response.isLatest,
+ status: response.status,
+
+ // 벤더 정보
+ vendor: {
+ id: response.vendorId,
+ code: response.vendorCode,
+ name: response.vendorName,
+ email: response.vendorEmail,
+ responseCount: response.vendorResponseCount,
},
- sparePart: {
- required: response.vendorSparepartYn,
- acceptance: response.vendorSparepartAcceptance,
+
+ // 제출 정보
+ submission: {
+ submittedAt: response.submittedAt,
+ submittedBy: response.submittedBy,
+ submittedByName: response.submittedByName,
},
- },
-
- // 카운트 정보
- counts: {
- quotedItems: response.quotedItemCount,
- attachments: response.attachmentCount,
- },
-
- // 비고
- remarks: {
- general: response.generalRemark,
- technical: response.technicalProposal,
- },
-
- // 타임스탬프
- timestamps: {
- createdAt: response.createdAt,
- updatedAt: response.updatedAt,
- },
- }));
+
+ // 제출 정보
+ attend: {
+ participationStatus: response.participationStatus,
+ participationRepliedAt: response.participationRepliedAt,
+ participationRepliedBy: response.participationRepliedBy,
+ nonParticipationReason: response.nonParticipationReason,
+ },
+
+ // 금액 정보
+ pricing: {
+ totalAmount: response.totalAmount,
+ currency: response.currency || "USD",
+ vendorCurrency: response.vendorCurrency,
+ },
+
+ // 벤더 제안 조건
+ vendorTerms: {
+ paymentTermsCode: response.vendorPaymentTermsCode,
+ incotermsCode: response.vendorIncotermsCode,
+ incotermsDetail: response.vendorIncotermsDetail,
+ deliveryDate: response.vendorDeliveryDate,
+ contractDuration: response.vendorContractDuration,
+ taxCode: response.vendorTaxCode,
+ placeOfShipping: response.vendorPlaceOfShipping,
+ placeOfDestination: response.vendorPlaceOfDestination,
+ },
+
+ // 초도품/Spare part/연동제
+ additionalRequirements: {
+ firstArticle: {
+ required: response.vendorFirstYn,
+ description: response.vendorFirstDescription,
+ acceptance: response.vendorFirstAcceptance,
+ },
+ sparePart: {
+ required: response.vendorSparepartYn,
+ description: response.vendorSparepartDescription,
+ acceptance: response.vendorSparepartAcceptance,
+ },
+ materialPriceRelated: {
+ required: response.vendorMaterialPriceRelatedYn,
+ reason: response.vendorMaterialPriceRelatedReason,
+ },
+ },
+
+ // 변경 사유
+ changeReasons: {
+ currency: response.currencyReason,
+ paymentTerms: response.paymentTermsReason,
+ deliveryDate: response.deliveryDateReason,
+ incoterms: response.incotermsReason,
+ tax: response.taxReason,
+ shipping: response.shippingReason,
+ },
+
+ // 카운트 정보
+ counts: {
+ quotedItems: response.quotationItems.length,
+ attachments: response.attachments.length,
+ },
+
+ // 비고
+ remarks: {
+ general: response.generalRemark,
+ technical: response.technicalProposal,
+ },
+
+ // 견적 아이템 상세
+ quotationItems: response.quotationItems.map(item => ({
+ id: item.id,
+ rfqPrItemId: item.rfqPrItemId,
+ prNo: item.prNo,
+ materialCode: item.materialCode,
+ materialDescription: item.materialDescription,
+ quantity: item.quantity,
+ uom: item.uom,
+ unitPrice: item.unitPrice,
+ totalPrice: item.totalPrice,
+ currency: item.currency,
+ vendorDeliveryDate: item.vendorDeliveryDate,
+ leadTime: item.leadTime,
+ manufacturer: item.manufacturer,
+ manufacturerCountry: item.manufacturerCountry,
+ modelNo: item.modelNo,
+ technicalCompliance: item.technicalCompliance,
+ alternativeProposal: item.alternativeProposal,
+ discountRate: item.discountRate,
+ discountAmount: item.discountAmount,
+ itemRemark: item.itemRemark,
+ deviationReason: item.deviationReason,
+ })),
+
+ // 첨부파일 상세
+ attachments: response.attachments.map(file => ({
+ id: file.id,
+ attachmentType: file.attachmentType,
+ documentNo: file.documentNo,
+ fileName: file.fileName,
+ originalFileName: file.originalFileName,
+ filePath: file.filePath, // 파일 경로 포함
+ fileSize: file.fileSize,
+ fileType: file.fileType,
+ description: file.description,
+ validFrom: file.validFrom,
+ validTo: file.validTo,
+ uploadedBy: file.uploadedBy,
+ uploadedByName: file.uploadedByName,
+ uploadedAt: file.uploadedAt,
+ })),
+
+ // 타임스탬프
+ timestamps: {
+ createdAt: response.createdAt,
+ updatedAt: response.updatedAt,
+ },
+ }));
return {
success: true,
@@ -1706,11 +1841,16 @@ export async function getRfqWithDetails(rfqId: number) {
const details = await db
.select()
.from(rfqLastDetailsView)
- .where(eq(rfqLastDetailsView.rfqId, rfqId))
+ .where(
+ and(
+ eq(rfqLastDetailsView.rfqId, rfqId),
+ eq(rfqLastDetailsView.isLatest, true) // isLatest 필터 추가
+ )
+ )
.orderBy(desc(rfqLastDetailsView.detailId));
- return {
- success: true,
+ return {
+ success: true,
data: {
// RFQ 기본 정보 (rfqsLastView에서 제공)
id: rfqData.id,
@@ -1719,58 +1859,58 @@ export async function getRfqWithDetails(rfqId: number) {
rfqTitle: rfqData.rfqTitle,
series: rfqData.series,
rfqSealedYn: rfqData.rfqSealedYn,
-
+
// ITB 관련
projectCompany: rfqData.projectCompany,
projectFlag: rfqData.projectFlag,
projectSite: rfqData.projectSite,
smCode: rfqData.smCode,
-
+
// PR 정보
prNumber: rfqData.prNumber,
prIssueDate: rfqData.prIssueDate,
-
+
// 프로젝트 정보
projectId: rfqData.projectId,
projectCode: rfqData.projectCode,
projectName: rfqData.projectName,
-
+
// 아이템 정보
itemCode: rfqData.itemCode,
itemName: rfqData.itemName,
-
+
// 패키지 정보
packageNo: rfqData.packageNo,
packageName: rfqData.packageName,
-
+
// 날짜 및 상태
dueDate: rfqData.dueDate,
rfqSendDate: rfqData.rfqSendDate,
status: rfqData.status,
-
+
// PIC 정보
picId: rfqData.picId,
picCode: rfqData.picCode,
picName: rfqData.picName,
picUserName: rfqData.picUserName,
engPicName: rfqData.engPicName,
-
+
// 집계 정보 (View에서 이미 계산됨)
vendorCount: rfqData.vendorCount,
shortListedVendorCount: rfqData.shortListedVendorCount,
quotationReceivedCount: rfqData.quotationReceivedCount,
prItemsCount: rfqData.prItemsCount,
majorItemsCount: rfqData.majorItemsCount,
-
+
// 견적 제출 정보
earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt,
-
+
// Major Item 정보
majorItemMaterialCode: rfqData.majorItemMaterialCode,
majorItemMaterialDescription: rfqData.majorItemMaterialDescription,
majorItemMaterialCategory: rfqData.majorItemMaterialCategory,
majorItemPrNo: rfqData.majorItemPrNo,
-
+
// 감사 정보
createdBy: rfqData.createdBy,
createdByUserName: rfqData.createdByUserName,
@@ -1780,20 +1920,20 @@ export async function getRfqWithDetails(rfqId: number) {
updatedBy: rfqData.updatedBy,
updatedByUserName: rfqData.updatedByUserName,
updatedAt: rfqData.updatedAt,
-
+
// 비고
remark: rfqData.remark,
-
+
// 벤더별 상세 조건 (rfqLastDetailsView에서 제공)
details: details.map(d => ({
detailId: d.detailId,
-
+
// 벤더 정보
vendorId: d.vendorId,
vendorName: d.vendorName,
vendorCode: d.vendorCode,
vendorCountry: d.vendorCountry,
-
+
// 조건 정보
currency: d.currency,
paymentTermsCode: d.paymentTermsCode,
@@ -1806,7 +1946,7 @@ export async function getRfqWithDetails(rfqId: number) {
taxCode: d.taxCode,
placeOfShipping: d.placeOfShipping,
placeOfDestination: d.placeOfDestination,
-
+
// Boolean 필드들
shortList: d.shortList,
returnYn: d.returnYn,
@@ -1818,13 +1958,13 @@ export async function getRfqWithDetails(rfqId: number) {
materialPriceRelatedYn: d.materialPriceRelatedYn,
sparepartYn: d.sparepartYn,
firstYn: d.firstYn,
-
+
// 설명 필드
firstDescription: d.firstDescription,
sparepartDescription: d.sparepartDescription,
remark: d.remark,
cancelReason: d.cancelReason,
-
+
// 견적 관련 정보 (View에서 이미 계산됨)
hasQuotation: d.hasQuotation,
quotationStatus: d.quotationStatus,
@@ -1833,11 +1973,18 @@ export async function getRfqWithDetails(rfqId: number) {
quotationVersionCount: d.quotationVersionCount,
lastQuotationDate: d.lastQuotationDate,
quotationSubmittedAt: d.quotationSubmittedAt,
-
+
// 감사 정보
updatedBy: d.updatedBy,
updatedByUserName: d.updatedByUserName,
updatedAt: d.updatedAt,
+
+ sendVersion: d.sendVersion,
+ emailSentAt: d.emailSentAt,
+ emailSentTo: d.emailSentTo,
+ emailResentCount: d.emailResentCount,
+ lastEmailSentAt: d.lastEmailSentAt,
+ emailStatus: d.emailStatus,
})),
}
};
@@ -1857,59 +2004,59 @@ export interface RfqFullInfo {
rfqTitle: string | null;
series: string | null;
rfqSealedYn: boolean | null;
-
+
// ITB 관련
projectCompany: string | null;
projectFlag: string | null;
projectSite: string | null;
smCode: string | null;
-
+
// RFQ 추가 필드
prNumber: string | null;
prIssueDate: Date | null;
-
+
// 프로젝트 정보
projectId: number | null;
projectCode: string | null;
projectName: string | null;
-
+
// 아이템 정보
itemCode: string | null;
itemName: string | null;
-
+
// 패키지 정보
packageNo: string | null;
packageName: string | null;
-
+
// 날짜 정보
dueDate: Date | null;
rfqSendDate: Date | null;
-
+
// 상태
status: string;
-
+
// 담당자 정보
picId: number | null;
picCode: string | null;
picName: string | null;
picUserName: string | null;
picTeam: string | null;
-
+
// 설계담당자
engPicName: string | null;
designTeam: string | null;
-
+
// 자재그룹 정보 (PR Items에서)
materialGroup: string | null;
materialGroupDesc: string | null;
-
+
// 카운트 정보
vendorCount: number;
shortListedVendorCount: number;
quotationReceivedCount: number;
prItemsCount: number;
majorItemsCount: number;
-
+
// 감사 정보
createdBy: number;
createdByUserName: string | null;
@@ -1917,17 +2064,17 @@ export interface RfqFullInfo {
updatedBy: number;
updatedByUserName: string | null;
updatedAt: Date;
-
+
sentBy: number | null;
sentByUserName: string | null;
-
+
remark: string | null;
-
+
// 평가 적용 여부 (추가 필드)
evaluationApply?: boolean;
quotationType?: string;
contractType?: string;
-
+
// 연관 데이터
vendors: VendorDetail[];
attachments: AttachmentInfo[];
@@ -1944,7 +2091,7 @@ export interface VendorDetail {
vendorCategory?: string | null;
vendorGrade?: string | null;
basicContract?: string | null;
-
+
// RFQ 조건
currency: string | null;
paymentTermsCode: string | null;
@@ -1957,32 +2104,32 @@ export interface VendorDetail {
taxCode: string | null;
placeOfShipping: string | null;
placeOfDestination: string | null;
-
+
// 상태
shortList: boolean;
returnYn: boolean;
returnedAt: Date | null;
-
+
// GTC/NDA
prjectGtcYn: boolean;
generalGtcYn: boolean;
ndaYn: boolean;
agreementYn: boolean;
-
+
// 추가 조건
materialPriceRelatedYn: boolean | null;
sparepartYn: boolean | null;
firstYn: boolean | null;
firstDescription: string | null;
sparepartDescription: string | null;
-
+
remark: string | null;
cancelReason: string | null;
-
+
// 회신 상태
quotationStatus?: string | null;
quotationSubmittedAt?: Date | null;
-
+
// 업데이트 정보
updatedBy: number;
updatedByUserName: string | null;
@@ -1996,14 +2143,14 @@ export interface AttachmentInfo {
serialNo: string;
currentRevision: string;
description: string | null;
-
+
// 최신 리비전 정보
fileName: string | null;
originalFileName: string | null;
filePath: string | null;
fileSize: number | null;
fileType: string | null;
-
+
createdBy: number;
createdByUserName: string | null;
createdAt: Date;
@@ -2075,7 +2222,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
vendorCategory: v.vendor?.vendorCategory ?? null,
vendorGrade: v.vendor?.vendorGrade ?? null,
basicContract: v.vendor?.basicContract ?? null,
-
+
currency: v.detail.currency,
paymentTermsCode: v.detail.paymentTermsCode,
paymentTermsDescription: v.paymentTerms?.description ?? null,
@@ -2087,25 +2234,25 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
taxCode: v.detail.taxCode,
placeOfShipping: v.detail.placeOfShipping,
placeOfDestination: v.detail.placeOfDestination,
-
+
shortList: v.detail.shortList,
returnYn: v.detail.returnYn,
returnedAt: v.detail.returnedAt,
-
+
prjectGtcYn: v.detail.prjectGtcYn,
generalGtcYn: v.detail.generalGtcYn,
ndaYn: v.detail.ndaYn,
agreementYn: v.detail.agreementYn,
-
+
materialPriceRelatedYn: v.detail.materialPriceRelatedYn,
sparepartYn: v.detail.sparepartYn,
firstYn: v.detail.firstYn,
firstDescription: v.detail.firstDescription,
sparepartDescription: v.detail.sparepartDescription,
-
+
remark: v.detail.remark,
cancelReason: v.detail.cancelReason,
-
+
updatedBy: v.detail.updatedBy,
updatedByUserName: v.updatedByUser?.name ?? null,
updatedAt: v.detail.updatedAt,
@@ -2135,13 +2282,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
serialNo: a.attachment.serialNo,
currentRevision: a.attachment.currentRevision,
description: a.attachment.description,
-
+
fileName: a.revision?.fileName ?? null,
originalFileName: a.revision?.originalFileName ?? null,
filePath: a.revision?.filePath ?? null,
fileSize: a.revision?.fileSize ?? null,
fileType: a.revision?.fileType ?? null,
-
+
createdBy: a.attachment.createdBy,
createdByUserName: a.createdByUser?.name ?? null,
createdAt: a.attachment.createdAt,
@@ -2152,13 +2299,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
const vendorCount = vendorDetails.length;
const shortListedVendorCount = vendorDetails.filter(v => v.shortList).length;
const quotationReceivedCount = vendorDetails.filter(v => v.quotationSubmittedAt).length;
-
+
// PR Items 카운트 (별도 쿼리 필요)
const prItemsCount = await db
.select({ count: sql<number>`COUNT(*)` })
.from(rfqPrItems)
.where(eq(rfqPrItems.rfqsLastId, rfqId));
-
+
const majorItemsCount = await db
.select({ count: sql<number>`COUNT(*)` })
.from(rfqPrItems)
@@ -2173,19 +2320,19 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
.from(users)
.where(eq(users.id, rfq.createdBy))
.limit(1);
-
+
const [updatedByUser] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, rfq.updatedBy))
.limit(1);
-
- const [sentByUser] = rfq.sentBy
+
+ const [sentByUser] = rfq.sentBy
? await db
- .select({ name: users.name })
- .from(users)
- .where(eq(users.id, rfq.sentBy))
- .limit(1)
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.sentBy))
+ .limit(1)
: [null];
// 7. 전체 정보 조합
@@ -2197,59 +2344,59 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
rfqTitle: rfq.rfqTitle,
series: rfq.series,
rfqSealedYn: rfq.rfqSealedYn,
-
+
// ITB 관련
projectCompany: rfq.projectCompany,
projectFlag: rfq.projectFlag,
projectSite: rfq.projectSite,
smCode: rfq.smCode,
-
+
// RFQ 추가 필드
prNumber: rfq.prNumber,
prIssueDate: rfq.prIssueDate,
-
+
// 프로젝트
projectId: rfq.projectId,
projectCode: null, // 프로젝트 조인 필요시 추가
projectName: null, // 프로젝트 조인 필요시 추가
-
+
// 아이템
itemCode: rfq.itemCode,
itemName: rfq.itemName,
-
+
// 패키지
packageNo: rfq.packageNo,
packageName: rfq.packageName,
-
+
// 날짜
dueDate: rfq.dueDate,
rfqSendDate: rfq.rfqSendDate,
-
+
// 상태
status: rfq.status,
-
+
// 구매 담당자
picId: rfq.pic,
picCode: rfq.picCode,
picName: rfq.picName,
picUserName: picUser?.name ?? null,
picTeam: picUser?.department ?? null, // users 테이블에 department 필드가 있다고 가정
-
+
// 설계 담당자
engPicName: rfq.EngPicName,
designTeam: null, // 추가 정보 필요시 입력
-
+
// 자재그룹 (PR Items에서)
materialGroup: majorItem?.materialCategory ?? null,
materialGroupDesc: majorItem?.materialDescription ?? null,
-
+
// 카운트
vendorCount,
shortListedVendorCount,
quotationReceivedCount,
prItemsCount: prItemsCount[0]?.count ?? 0,
majorItemsCount: majorItemsCount[0]?.count ?? 0,
-
+
// 감사 정보
createdBy: rfq.createdBy,
createdByUserName: createdByUser?.name ?? null,
@@ -2259,14 +2406,14 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
updatedAt: rfq.updatedAt,
sentBy: rfq.sentBy,
sentByUserName: sentByUser?.name ?? null,
-
+
remark: rfq.remark,
-
+
// 추가 필드 (필요시)
evaluationApply: true, // 기본값 또는 별도 로직
quotationType: rfq.rfqType ?? undefined,
contractType: undefined, // 별도 필드 필요
-
+
// 연관 데이터
vendors: vendorDetails,
attachments: attachments,
@@ -2284,7 +2431,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
*/
export async function getRfqInfoForSend(rfqId: number) {
const fullInfo = await getRfqFullInfo(rfqId);
-
+
return {
rfqCode: fullInfo.rfqCode,
rfqTitle: fullInfo.rfqTitle || '',
@@ -2369,6 +2516,7 @@ export interface VendorEmailInfo {
contactEmails: string[]; // 영업/대표 담당자 이메일들
primaryEmail?: string | null; // 최종 선택된 주 이메일
currency?: string | null;
+ currency?: string | null;
}
/**
@@ -2448,9 +2596,6 @@ export async function getRfqSendData(rfqId: number): Promise<RfqSendData> {
packageNo: rfq.packageNo || undefined,
packageName: rfq.packageName || undefined,
designPicName: rfq.EngPicName || undefined,
- rfqTitle: rfq.rfqTitle || undefined,
- rfqType: rfq.rfqType || undefined,
- designTeam: undefined, // 필요시 추가 조회
materialGroup: majorItem?.materialCategory || undefined,
materialGroupDesc: majorItem?.materialDescription || undefined,
dueDate: rfq.dueDate || new Date(),
@@ -2515,7 +2660,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma
// 3. 데이터 조합
const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => {
const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id);
-
+
// ContactDetail 형식으로 변환
const contacts: ContactDetail[] = vendorContacts.map(c => ({
id: c.id,
@@ -2526,7 +2671,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma
phone: c.contactPhone,
isPrimary: c.isPrimary,
}));
-
+
// 포지션별로 그룹화
const contactsByPosition: Record<string, ContactDetail[]> = {};
contacts.forEach(contact => {
@@ -2536,7 +2681,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma
}
contactsByPosition[position].push(contact);
});
-
+
// 주 이메일 선택 우선순위:
// 1. isPrimary가 true인 담당자 이메일
// 2. 대표자 이메일
@@ -2545,7 +2690,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma
// 5. 첫번째 담당자 이메일
const primaryContact = contacts.find(c => c.isPrimary);
const salesContact = contacts.find(c => c.position === '영업');
- const primaryEmail =
+ const primaryEmail =
primaryContact?.email ||
vendor.representativeEmail ||
vendor.email ||
@@ -2584,17 +2729,23 @@ export async function getSelectedVendorsWithEmails(
try {
// 1. 벤더 이메일 정보 조회
const vendorEmailInfos = await getVendorEmailInfo(vendorIds);
-
+
// 2. RFQ Detail에서 통화 정보 조회 (옵션)
const rfqDetailsData = await db
.select({
vendorId: rfqLastDetails.vendorsId,
currency: rfqLastDetails.currency,
+ ndaYn: rfqLastDetails.ndaYn,
+ generalGtcYn: rfqLastDetails.generalGtcYn,
+ projectGtcYn: rfqLastDetails.projectGtcYn,
+ agreementYn: rfqLastDetails.agreementYn,
+ sendVersion: rfqLastDetails.sendVersion,
})
.from(rfqLastDetails)
.where(
and(
eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true),
sql`${rfqLastDetails.vendorsId} IN ${vendorIds}`
)
);
@@ -2605,6 +2756,11 @@ export async function getSelectedVendorsWithEmails(
return {
...vendor,
currency: detail?.currency || vendor.currency || 'KRW',
+ ndaYn: detail?.ndaYn,
+ generalGtcYn: detail?.generalGtcYn,
+ projectGtcYn: detail?.projectGtcYn,
+ agreementYn: detail?.agreementYn,
+ sendVersion: detail?.sendVersion
};
});
@@ -2613,4 +2769,977 @@ export async function getSelectedVendorsWithEmails(
console.error("선택된 벤더 정보 조회 실패:", error);
throw error;
}
+}
+
+interface SendRfqVendor {
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails: Array<{ id: string; email: string; name?: string }>;
+}
+
+export interface ContractRequirements {
+ ndaYn: boolean;
+ generalGtcYn: boolean;
+ projectGtcYn: boolean;
+ agreementYn: boolean;
+ projectCode?: string; // Project GTC를 위한 프로젝트 코드
+}
+
+export interface VendorForSend {
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails?: Array<{ email: string; name?: string }>;
+ currency?: string | null;
+
+ // 기본계약 관련
+ contractRequirements?: ContractRequirements;
+
+ // 재발송 관련
+ isResend: boolean;
+ sendVersion?: number;
+}
+
+export interface SendRfqParams {
+ rfqId: number;
+ rfqCode?: string;
+ vendors: VendorForSend[];
+ attachmentIds: number[];
+ message?: string;
+}
+
+export async function sendRfqToVendors({
+ rfqId,
+ rfqCode,
+ vendors,
+ attachmentIds,
+ message,
+ generatedPdfs
+}: SendRfqParams & {
+ generatedPdfs?: Array<{
+ key: string;
+ buffer: number[];
+ fileName: string;
+ }>;
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const currentUser = session.user
+
+ // 트랜잭션 시작
+ const result = await db.transaction(async (tx) => {
+ // 1. RFQ 정보 조회
+ const [rfqData] = await tx
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqType: rfqsLast.rfqType,
+ rfqTitle: rfqsLast.rfqTitle,
+ projectId: rfqsLast.projectId,
+ itemCode: rfqsLast.itemCode,
+ itemName: rfqsLast.itemName,
+ dueDate: rfqsLast.dueDate,
+ packageNo: rfqsLast.packageNo,
+ packageName: rfqsLast.packageName,
+ picId: rfqsLast.pic,
+ picCode: rfqsLast.picCode,
+ picName: rfqsLast.picName,
+ projectCompany: rfqsLast.projectCompany,
+ projectFlag: rfqsLast.projectFlag,
+ projectSite: rfqsLast.projectSite,
+ smCode: rfqsLast.smCode,
+ prNumber: rfqsLast.prNumber,
+ prIssueDate: rfqsLast.prIssueDate,
+ series: rfqsLast.series,
+ EngPicName: rfqsLast.EngPicName,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId));
+
+ if (!rfqData) {
+ throw new Error("RFQ 정보를 찾을 수 없습니다.");
+ }
+
+ // 2. PIC 사용자 정보 조회
+ let picEmail = process.env.Email_From_Address;
+ let picName = rfqData.picName || "구매담당자";
+
+ if (rfqData.picId) {
+ const [picUser] = await tx
+ .select()
+ .from(users)
+ .where(eq(users.id, rfqData.picId));
+
+ if (picUser?.email) {
+ picEmail = picUser.email;
+ picName = picUser.name || picName;
+ }
+ }
+
+ // 3. 프로젝트 정보 조회
+ let projectInfo = null;
+ if (rfqData.projectId) {
+ const [project] = await tx
+ .select()
+ .from(projects)
+ .where(eq(projects.id, rfqData.projectId));
+ projectInfo = project;
+ }
+
+ // 4. PR 아이템 정보 조회
+ const prItems = await tx
+ .select()
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ // 5. 첨부파일 정보 조회 및 준비
+ const attachments = await tx
+ .select({
+ attachment: rfqLastAttachments,
+ revision: rfqLastAttachmentRevisions
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .where(
+ and(
+ eq(rfqLastAttachments.rfqId, rfqId),
+ attachmentIds.length > 0
+ ? sql`${rfqLastAttachments.id} IN (${sql.join(attachmentIds, sql`, `)})`
+ : sql`1=1`
+ )
+ );
+
+ // 6. 이메일 첨부파일 준비
+ const emailAttachments = [];
+ for (const { attachment, revision } of attachments) {
+ if (revision?.filePath) {
+ try {
+ const fullPath = path.join(process.cwd(), `${process.env.NAS_PATH}`, revision.filePath);
+ const fileBuffer = await fs.readFile(fullPath);
+ emailAttachments.push({
+ filename: revision.originalFileName,
+ content: fileBuffer,
+ contentType: revision.fileType || 'application/octet-stream'
+ });
+ } catch (error) {
+ console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error);
+ }
+ }
+ }
+
+ // ========== TBE용 설계 문서 조회 (중요!) ==========
+ const designAttachments = await tx
+ .select({
+ attachment: rfqLastAttachments,
+ revision: rfqLastAttachmentRevisions
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .where(
+ and(
+ eq(rfqLastAttachments.rfqId, rfqId),
+ eq(rfqLastAttachments.attachmentType, "설계") // 설계 문서만 필터링
+ )
+ );
+
+
+ // 7. 각 벤더별 처리
+ const results = [];
+ const errors = [];
+ const savedContracts = [];
+ const tbeSessionsCreated = [];
+
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
+
+ for (const vendor of vendors) {
+ // 재발송 여부 확인
+ const isResend = vendor.isResend || false;
+ const sendVersion = (vendor.sendVersion || 0) + 1;
+
+ // 7.4 이메일 수신자 정보 준비
+ const toEmails = [vendor.selectedMainEmail];
+ const ccEmails = [...vendor.additionalEmails];
+
+ vendor.customEmails?.forEach(custom => {
+ if (custom.email !== vendor.selectedMainEmail &&
+ !vendor.additionalEmails.includes(custom.email)) {
+ ccEmails.push(custom.email);
+ }
+ });
+
+ // 이메일 수신자 정보를 JSON으로 저장
+ const emailRecipients = {
+ to: toEmails,
+ cc: ccEmails,
+ sentBy: picEmail
+ };
+
+ try {
+ // 7.1 rfqLastDetails 조회 또는 생성
+ let [rfqDetail] = await tx
+ .select()
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendor.vendorId),
+ eq(rfqLastDetails.isLatest, true),
+ )
+ );
+
+ if (!rfqDetail) {
+ throw new Error("해당 RFQ에는 벤더가 이미 할당되어있는 상태이어야합니다.");
+ }
+
+ // 기존 rfqDetail을 isLatest=false로 업데이트
+ const updateResult = await tx
+ .update(rfqLastDetails)
+ .set({
+ isLatest: false,
+ updatedAt: new Date() // 업데이트 시간도 기록
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendor.vendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .returning({ id: rfqLastDetails.id });
+
+ console.log(`Updated ${updateResult.length} records to isLatest=false for vendor ${vendor.vendorId}`);
+
+ const { id, updatedBy, updatedAt, isLatest, sendVersion: oldSendVersion, emailResentCount, ...restRfqDetail } = rfqDetail;
+
+
+ let [newRfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ ...restRfqDetail, // 기존 값들 복사
+
+ // 업데이트할 필드들
+ updatedBy: Number(currentUser.id),
+ updatedAt: new Date(),
+ isLatest: true,
+
+ // 이메일 관련 필드 업데이트
+ emailSentAt: new Date(),
+ emailSentTo: JSON.stringify(emailRecipients),
+ emailResentCount: isResend ? (emailResentCount || 0) + 1 : 1,
+ sendVersion: sendVersion,
+ lastEmailSentAt: new Date(),
+ emailStatus: "sent",
+
+ agreementYn: vendor.contractRequirements.agreementYn || false,
+ ndaYn: vendor.contractRequirements.ndaYn || false,
+ projectGtcYn: vendor.contractRequirements.projectGtcYn || false,
+ generalGtcYn: vendor.contractRequirements.generalGtcYn || false,
+
+
+ })
+ .returning();
+
+
+ // 생성된 PDF 저장 및 DB 기록
+ if (generatedPdfs && vendor.contractRequirements) {
+ const vendorPdfs = generatedPdfs.filter(pdf =>
+ pdf.key.startsWith(`${vendor.vendorId}_`)
+ );
+
+ for (const pdfData of vendorPdfs) {
+ console.log(vendor.vendorId, pdfData.buffer.length)
+ // PDF 파일 저장
+ const pdfBuffer = Buffer.from(pdfData.buffer);
+ const fileName = pdfData.fileName;
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, pdfBuffer);
+
+ const templateName = pdfData.key.split('_')[2];
+
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", templateName, template)
+
+ // 기존 계약이 있는지 확인
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.rfqCompanyId, newRfqDetail.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: Number(currentUser.id),
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: addDays(new Date(), 10),
+ updatedAt: new Date(),
+ // version을 증가시키거나 이력 관리가 필요하면 추가
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id)
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template.id,
+ vendorId: vendor.vendorId,
+ rfqCompanyId: newRfqDetail.id,
+ requestedBy: Number(currentUser.id),
+ status: "PENDING",
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: addDays(new Date(), 10),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id)
+ }
+
+ console.log(contractRecord.vendorId, contractRecord.filePath)
+
+ savedContracts.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ templateName: templateName,
+ contractId: contractRecord.id,
+ fileName: fileName,
+ isUpdated: !!existingContract // 업데이트 여부 표시
+ });
+ }
+ }
+
+ // 7.3 기존 응답 레코드 확인
+ const existingResponses = await tx
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendor.vendorId)
+ )
+ );
+
+ // 7.4 기존 응답이 있으면 isLatest=false로 업데이트
+ if (existingResponses.length > 0) {
+ await tx
+ .update(rfqLastVendorResponses)
+ .set({
+ isLatest: false
+ })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.vendorId, vendor.vendorId),
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ )
+ )
+ }
+
+ // 7.5 새로운 응답 레코드 생성
+ const newResponseVersion = existingResponses.length > 0
+ ? Math.max(...existingResponses.map(r => r.responseVersion)) + 1
+ : 1;
+
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: newRfqDetail.id,
+ vendorId: vendor.vendorId,
+ responseVersion: newResponseVersion,
+ isLatest: true,
+ status: "초대됨",
+ currency: rfqDetail.currency || "USD",
+
+ // 감사 필드
+ createdBy: currentUser.id,
+ updatedBy: currentUser.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+
+ // ========== TBE 세션 및 문서 검토 레코드 생성 시작 ==========
+ // 7.4 기존 활성 TBE 세션 확인
+ const [existingActiveTbe] = await tx
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfqId),
+ eq(rfqLastTbeSessions.vendorId, vendor.vendorId),
+ sql`${rfqLastTbeSessions.status} IN ('준비중', '진행중', '검토중', '보류')`
+ )
+ );
+
+ // 7.5 활성 TBE 세션이 없는 경우에만 새로 생성
+ if (!existingActiveTbe) {
+ // TBE 세션 코드 생성
+ const year = new Date().getFullYear();
+ const [lastTbeSession] = await tx
+ .select({ sessionCode: rfqLastTbeSessions.sessionCode })
+ .from(rfqLastTbeSessions)
+ .where(sql`${rfqLastTbeSessions.sessionCode} LIKE 'TBE-${year}-%'`)
+ .orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`)
+ .limit(1);
+
+ let sessionNumber = 1;
+ if (lastTbeSession?.sessionCode) {
+ const lastNumber = parseInt(lastTbeSession.sessionCode.split('-')[2]);
+ sessionNumber = isNaN(lastNumber) ? 1 : lastNumber + 1;
+ }
+
+ const sessionCode = `TBE-${year}-${String(sessionNumber).padStart(3, '0')}`;
+
+ // TBE 세션 생성
+ const [tbeSession] = await tx
+ .insert(rfqLastTbeSessions)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: newRfqDetail.id,
+ vendorId: vendor.vendorId,
+ sessionCode: sessionCode,
+ sessionTitle: `${rfqData.rfqCode} - ${vendor.vendorName} 기술검토`,
+ sessionType: "initial",
+ status: "준비중",
+ evaluationResult: null,
+ plannedStartDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 1) : addDays(new Date(), 14),
+ plannedEndDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 7) : addDays(new Date(), 21),
+ leadEvaluatorId: rfqData.picId,
+ createdBy: Number(currentUser.id),
+ updatedBy: Number(currentUser.id),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log(`TBE 세션 생성됨: ${sessionCode} for vendor ${vendor.vendorName}`);
+
+ // ========== 설계 문서에 대한 검토 레코드 생성 (중요!) ==========
+ const documentReviewsCreated = [];
+
+ for (const { attachment, revision } of designAttachments) {
+ const [documentReview] = await tx
+ .insert(rfqLastTbeDocumentReviews)
+ .values({
+ tbeSessionId: tbeSession.id,
+ documentSource: "buyer",
+ buyerAttachmentId: attachment.id,
+ buyerAttachmentRevisionId: revision?.id || null,
+ vendorAttachmentId: null, // 구매자 문서이므로 null
+ documentType: attachment.attachmentType,
+ documentName: revision?.originalFileName || attachment.serialNo,
+ reviewStatus: "미검토",
+ technicalCompliance: null,
+ qualityAcceptable: null,
+ requiresRevision: false,
+ reviewComments: null,
+ revisionRequirements: null,
+ hasPdftronComments: false,
+ pdftronDocumentId: null,
+ pdftronAnnotationCount: 0,
+ reviewedBy: null,
+ reviewedAt: null,
+ additionalReviewers: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ documentReviewsCreated.push({
+ reviewId: documentReview.id,
+ attachmentId: attachment.id,
+ documentName: documentReview.documentName
+ });
+
+ console.log(`문서 검토 레코드 생성: ${documentReview.documentName}`);
+ }
+
+ tbeSessionsCreated.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ sessionId: tbeSession.id,
+ sessionCode: tbeSession.sessionCode,
+ documentReviewsCount: documentReviewsCreated.length
+ });
+
+ console.log(`TBE 세션 ${sessionCode}: 총 ${documentReviewsCreated.length}개 문서 검토 레코드 생성`);
+ } else {
+ console.log(`TBE 세션이 이미 존재함: vendor ${vendor.vendorName}`);
+ }
+ // ========== TBE 세션 및 문서 검토 레코드 생성 끝 ==========
+
+ }
+
+ // 8. RFQ 상태 업데이트
+ if (results.length > 0) {
+ await tx
+ .update(rfqsLast)
+ .set({
+ status: "RFQ 발송",
+ rfqSendDate: new Date(),
+ sentBy: Number(currentUser.id),
+ updatedBy: Number(currentUser.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId));
+ }
+
+ return {
+ success: true,
+ results,
+ errors,
+ savedContracts,
+ totalSent: results.length,
+ totalFailed: errors.length,
+ totalContracts: savedContracts.length
+ };
+ });
+
+ return result;
+
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : "RFQ 발송 중 오류가 발생했습니다."
+ );
+ }
+}
+
+export async function updateRfqDueDate(
+ rfqId: number,
+ newDueDate: Date | string,
+ rfqCode: string,
+ rfqTitle: string
+) {
+ try {
+ // ✅ 날짜 정규화 - 문자열을 Date 객체로 변환
+ let normalizedDate: Date;
+
+ if (typeof newDueDate === 'string') {
+ // ISO 문자열인 경우 (2024-01-15T14:30:00.000Z)
+ if (newDueDate.includes('T')) {
+ normalizedDate = new Date(newDueDate);
+ }
+ // YYYY-MM-DD HH:mm 형식인 경우
+ else if (newDueDate.includes(' ') && newDueDate.includes(':')) {
+ normalizedDate = new Date(newDueDate);
+ }
+ // YYYY-MM-DD 형식인 경우 - 한국 시간 기준으로 설정
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(newDueDate)) {
+ normalizedDate = new Date(`${newDueDate}T00:00:00+09:00`);
+ }
+ else {
+ normalizedDate = new Date(newDueDate);
+ }
+ } else if (newDueDate instanceof Date) {
+ normalizedDate = newDueDate;
+ } else {
+ // Date 객체가 아닌 경우 처리
+ normalizedDate = new Date(newDueDate as any);
+ }
+
+ // 유효한 날짜인지 확인
+ if (isNaN(normalizedDate.getTime())) {
+ return {
+ success: false,
+ message: "유효하지 않은 날짜 형식입니다.",
+ }
+ }
+
+ // 1. RFQ 정보 조회
+ const rfqData = await db
+ .select()
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1)
+
+ if (!rfqData || rfqData.length === 0) {
+ return {
+ success: false,
+ message: "RFQ를 찾을 수 없습니다.",
+ }
+ }
+
+ const rfq = rfqData[0]
+ const oldDueDate = rfq.dueDate
+
+ // 2. Due Date 업데이트 - normalizedDate 사용
+ await db
+ .update(rfqsLast)
+ .set({
+ dueDate: normalizedDate,
+ updatedAt: new Date()
+ })
+ .where(eq(rfqsLast.id, rfqId))
+
+ // 3. 프로젝트 정보 조회 (있는 경우)
+ let projectInfo = null
+ if (rfq.projectId) {
+ const projectData = await db
+ .select()
+ .from(projects)
+ .where(eq(projects.id, rfq.projectId))
+ .limit(1)
+
+ if (projectData && projectData.length > 0) {
+ projectInfo = projectData[0]
+ }
+ }
+
+ // 4. PIC 정보 조회
+ let picInfo = null
+ if (rfq.pic) {
+ const picData = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, rfq.pic))
+ .limit(1)
+
+ if (picData && picData.length > 0) {
+ picInfo = picData[0]
+ }
+ }
+
+ const picName = picInfo?.name || rfq.picName || "구매팀"
+ const picEmail = picInfo?.email || process.env.Email_From_Address || "procurement@company.com"
+
+ // 5. RFQ Details에서 이메일 수신자 조회
+ const rfqDetailsData = await db
+ .select({
+ emailSentTo: rfqLastDetails.emailSentTo,
+ vendorId: rfqLastDetails.vendorsId,
+ })
+ .from(rfqLastDetails)
+ .where(eq(rfqLastDetails.rfqsLastId, rfqId))
+
+ if (rfqDetailsData.length === 0) {
+ // 페이지 재검증
+ revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, 'layout')
+
+ return {
+ success: true,
+ message: "마감일이 수정되었습니다. (발송된 이메일이 없음)",
+ }
+ }
+
+ // 6. 각 vendor별로 이메일 발송
+ const emailPromises = []
+
+ for (const detail of rfqDetailsData) {
+ if (!detail.emailSentTo) continue
+
+ // vendor 정보 조회
+ let vendorInfo = null
+ if (detail.vendorId) {
+ const vendorData = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, detail.vendorId))
+ .limit(1)
+
+ if (vendorData && vendorData.length > 0) {
+ vendorInfo = vendorData[0]
+ }
+ }
+
+ // 이메일 언어 결정 (vendor의 country가 KR이면 한국어, 아니면 영어)
+ const isKorean = vendorInfo?.country === 'KR'
+ const language = isKorean ? 'ko' : 'en'
+ const locale = isKorean ? ko : enUS
+
+ const emailSubject = isKorean
+ ? `[마감일 변경] ${rfqCode} ${rfqTitle || ''}`.trim()
+ : `[Due Date Changed] ${rfqCode} ${rfqTitle || ''}`.trim()
+
+ // ✅ 날짜 포맷팅 - 시간 포함하도록 수정
+ const oldDateFormatted = (() => {
+ try {
+ if (!oldDueDate) {
+ return isKorean ? "미설정" : "Not set";
+ }
+ const date = new Date(oldDueDate);
+ // 시간까지 포함한 포맷
+ return format(date, "PPP HH:mm", { locale });
+ } catch {
+ return isKorean ? "미설정" : "Not set";
+ }
+ })();
+
+ const newDateFormatted = (() => {
+ try {
+ // normalizedDate 사용
+ return format(normalizedDate, "PPP HH:mm", { locale });
+ } catch (error) {
+ console.error("Date formatting error:", error);
+ return normalizedDate.toISOString();
+ }
+ })();
+
+ // 이메일 발송 - null/undefined 값 처리
+ const emailContext: Record<string, unknown> = {
+ language: language ?? (isKorean ? "ko" : "en"),
+ vendorName: vendorInfo?.vendorName ?? "",
+ rfqCode: rfqCode ?? "",
+ rfqTitle: rfqTitle ?? "",
+ rfqType: rfq.rfqType ?? "",
+ projectCode: projectInfo?.code ?? "",
+ projectName: projectInfo?.name ?? "",
+ packageNo: rfq.packageNo ?? "",
+ packageName: rfq.packageName ?? "",
+ itemCode: rfq.itemCode ?? "",
+ itemName: rfq.itemName ?? "",
+ oldDueDate: oldDateFormatted,
+ newDueDate: newDateFormatted,
+ picName: picName ?? "구매팀",
+ picEmail: picEmail ?? (process.env.Email_From_Address ?? "procurement@company.com"),
+ engPicName: rfq.EngPicName ?? "",
+ portalUrl:
+ (process.env.NEXT_PUBLIC_APP_URL
+ ? `${process.env.NEXT_PUBLIC_APP_URL}/partners/rfq-last/${rfqId}`
+ : `https://partners.sevcp.com/partners/rfq-last/${rfqId}`),
+ };
+
+ const validContext = Object.fromEntries(
+ Object.entries(emailContext ?? {}).filter(([, value]) => value !== null && value !== undefined)
+ );
+
+ let toEmails: string[] = [];
+ let ccEmails: string[] = [];
+
+ try {
+ const emailData = typeof detail.emailSentTo === 'string'
+ ? JSON.parse(detail.emailSentTo)
+ : detail.emailSentTo;
+
+ if (emailData.to) {
+ toEmails = Array.isArray(emailData.to)
+ ? emailData.to.filter(Boolean)
+ : [emailData.to].filter(Boolean);
+ }
+
+ if (emailData.cc) {
+ ccEmails = Array.isArray(emailData.cc)
+ ? emailData.cc.filter(Boolean)
+ : [emailData.cc].filter(Boolean);
+ }
+ } catch (error) {
+ console.warn("Failed to parse emailSentTo as JSON, trying comma-separated:", error);
+ toEmails = (detail.emailSentTo ?? "")
+ .split(",")
+ .map((e) => e.trim())
+ .filter(Boolean);
+ }
+
+ console.log("Parsed emails - To:", toEmails, "CC:", ccEmails);
+
+ if (toEmails.length === 0) {
+ continue;
+ }
+
+ emailPromises.push(
+ sendEmail({
+ from: isDevelopment
+ ? (process.env.Email_From_Address ?? "no-reply@company.com")
+ : `"${picName}" <${picEmail}>`,
+ to: toEmails.join(", "),
+ cc: ccEmails.length > 0 ? ccEmails.join(", ") : undefined,
+ subject: emailSubject,
+ template: "rfq-due-date-change",
+ context: validContext,
+ })
+ );
+ }
+
+ // 모든 이메일 발송
+ if (emailPromises.length > 0) {
+ await Promise.allSettled(emailPromises)
+ }
+
+ try {
+ await revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, "layout");
+ } catch (e) {
+ console.warn("revalidatePath failed:", e);
+ }
+
+ // ✅ 성공 메시지도 시간 포함하도록 수정
+ return {
+ success: true,
+ message: `마감일이 ${format(normalizedDate, "yyyy년 MM월 dd일 HH시 mm분", { locale: ko })}로 수정되었으며, 관련 업체에 이메일이 발송되었습니다.`,
+ }
+ } catch (error) {
+ console.error("Error updating due date:", error)
+ return {
+ success: false,
+ message: "마감일 수정 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+
+export async function deleteRfqVendor({
+ rfqId,
+ detailId,
+ vendorId,
+}: {
+ rfqId: number;
+ detailId: number;
+ vendorId: number;
+}): Promise<{
+ success: boolean;
+ message?: string;
+}> {
+ try {
+ const deleteResult = await db
+ .delete(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.id, detailId),
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId)
+ )
+ )
+ .returning({ id: rfqLastDetails.id });
+
+ if (deleteResult.length === 0) {
+ throw new Error("삭제할 벤더를 찾을 수 없습니다.");
+ }
+
+ // 캐시 무효화
+ revalidatePath(`/partners/rfq-last/${rfqId}`);
+
+ return {
+ success: true,
+ message: "벤더가 성공적으로 삭제되었습니다.",
+ };
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "벤더 삭제 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function updateVendorContractRequirements({
+ rfqId,
+ detailId,
+ contractRequirements,
+}: UpdateVendorContractRequirementsParams): Promise<UpdateResult> {
+ try {
+ // gtcType에 따라 generalGtcYn과 projectGtcYn 설정
+ const generalGtcYn = contractRequirements.gtcType === "general";
+ const projectGtcYn = contractRequirements.gtcType === "project";
+
+ // 데이터베이스 업데이트
+ const result = await db
+ .update(rfqLastDetails)
+ .set({
+ agreementYn: contractRequirements.agreementYn,
+ ndaYn: contractRequirements.ndaYn,
+ gtcType: contractRequirements.gtcType,
+ generalGtcYn,
+ projectGtcYn,
+ updatedAt: new Date(),
+ // updatedBy는 세션에서 가져와야 하는 경우 추가
+ // updatedBy: getCurrentUserId(),
+ })
+ .where(eq(rfqLastDetails.id, detailId))
+ .returning();
+
+ // 결과 검증
+ if (!result || result.length === 0) {
+ return {
+ success: false,
+ error: "업체 정보를 찾을 수 없습니다.",
+ };
+ }
+
+ // // 캐시 재검증 (필요한 경우)
+ // revalidatePath(`/rfq/${rfqId}`);
+ // revalidatePath(`/rfq/${rfqId}/vendors`);
+
+ return {
+ success: true,
+ data: result[0],
+ };
+ } catch (error) {
+ console.error("Error updating vendor contract requirements:", error);
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 헬퍼 함수
+function getTemplateNameByType(
+ contractType: string,
+ requirements: any
+): string {
+ switch (contractType) {
+ case "NDA": return "비밀";
+ case "General_GTC": return "General GTC";
+ case "Project_GTC": return requirements.projectCode || "Project GTC";
+ case "기술자료": return "기술";
+ default: return contractType;
+ }
} \ No newline at end of file