summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx5
-rw-r--r--db/schema/rfqLast.ts6
-rw-r--r--lib/rfq-last/service.ts1252
-rw-r--r--lib/rfq-last/vendor-response/participation-dialog.tsx2
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx2
-rw-r--r--package-lock.json1
-rw-r--r--package.json1
7 files changed, 770 insertions, 499 deletions
diff --git a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
index b14df5c3..a0e278cb 100644
--- a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
+++ b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
@@ -63,6 +63,8 @@ export default async function VendorResponsePage({ params }: PageProps) {
</div>
)
}
+
+ console.log(vendor,"vendor")
// RFQ 정보 가져오기
const rfq = await db.query.rfqsLast.findFirst({
@@ -133,6 +135,9 @@ export default async function VendorResponsePage({ params }: PageProps) {
)
.orderBy(basicContract.createdAt)
+ console.log(basicContracts,"basicContracts")
+ console.log(rfqDetail,"rfqDetail")
+
return (
<div className="container mx-auto py-8">
diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts
index 615e57f4..766aeb6b 100644
--- a/db/schema/rfqLast.ts
+++ b/db/schema/rfqLast.ts
@@ -189,9 +189,9 @@ export const rfqPrItems = pgTable(
rfqsLastId: integer("rfqs_last_id")
.references(() => rfqsLast.id, { onDelete: "set null" }),
- rfqItem: varchar("rfq_item", { length: 50 }), // 단위
- prItem: varchar("pr_item", { length: 50 }), // 단위
- prNo: varchar("pr_no", { length: 50 }), // 단위
+ rfqItem: varchar("rfq_item", { length: 50 }),
+ prItem: varchar("pr_item", { length: 50 }),
+ prNo: varchar("pr_no", { length: 50 }),
// itemId: integer("item_id")
// .references(() => items.id, { onDelete: "set null" }),
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 7470428f..9943c02d 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -2829,539 +2829,803 @@ export async function sendRfqToVendors({
fileName: string;
}>;
}) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const currentUser = session.user;
+
try {
- const session = await getServerSession(authOptions)
+ // 1. RFQ 기본 정보 조회 (트랜잭션 외부)
+ const rfqData = await getRfqData(rfqId);
+ if (!rfqData) {
+ throw new Error("RFQ 정보를 찾을 수 없습니다.");
+ }
- if (!session?.user) {
- throw new Error("인증이 필요합니다.")
+ // 2. PIC 정보 조회
+ const picInfo = await getPicInfo(rfqData.picId, rfqData.picName);
+
+ // 3. 프로젝트 정보 조회
+ const projectInfo = rfqData.projectId
+ ? await getProjectInfo(rfqData.projectId)
+ : null;
+
+ // 4. 첨부파일 준비
+ const emailAttachments = await prepareEmailAttachments(rfqId, attachmentIds);
+ const designAttachments = await getDesignAttachments(rfqId);
+
+ // 5. 벤더별 처리
+ const { results, errors, savedContracts, tbeSessionsCreated } =
+ await processVendors({
+ rfqId,
+ rfqData,
+ vendors,
+ currentUser,
+ picInfo,
+ emailAttachments,
+ designAttachments,
+ generatedPdfs
+ });
+
+ // 6. RFQ 상태 업데이트
+ if (results.length > 0) {
+ await updateRfqStatus(rfqId, currentUser.id);
}
- const currentUser = session.user
+ return {
+ success: true,
+ results,
+ errors,
+ savedContracts,
+ tbeSessionsCreated,
+ totalSent: results.length,
+ totalFailed: errors.length,
+ totalContracts: savedContracts.length,
+ totalTbeSessions: tbeSessionsCreated.length
+ };
- // 트랜잭션 시작
- 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));
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : "RFQ 발송 중 오류가 발생했습니다."
+ );
+ }
+}
- if (!rfqData) {
- throw new Error("RFQ 정보를 찾을 수 없습니다.");
- }
+// ============= Helper Functions =============
+
+async function getRfqData(rfqId: number) {
+ const [rfqData] = await db
+ .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));
+
+ return rfqData;
+}
- // 2. PIC 사용자 정보 조회
- let picEmail = process.env.Email_From_Address;
- let picName = rfqData.picName || "구매담당자";
+async function getPicInfo(picId: number | null, picName: string | null) {
+ let picEmail = process.env.Email_From_Address;
+ let finalPicName = picName || "구매담당자";
- if (rfqData.picId) {
- const [picUser] = await tx
- .select()
- .from(users)
- .where(eq(users.id, rfqData.picId));
+ if (picId) {
+ const [picUser] = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, picId));
- if (picUser?.email) {
- picEmail = picUser.email;
- picName = picUser.name || picName;
- }
- }
+ if (picUser?.email) {
+ picEmail = picUser.email;
+ finalPicName = picUser.name || finalPicName;
+ }
+ }
- // 3. 프로젝트 정보 조회
- let projectInfo = null;
- if (rfqData.projectId) {
- const [project] = await tx
- .select()
- .from(projects)
- .where(eq(projects.id, rfqData.projectId));
- projectInfo = project;
- }
+ return { picEmail, picName: finalPicName };
+}
- // 4. PR 아이템 정보 조회
- const prItems = await tx
- .select()
- .from(rfqPrItems)
- .where(eq(rfqPrItems.rfqsLastId, rfqId));
+async function getProjectInfo(projectId: number) {
+ const [project] = await db
+ .select()
+ .from(projects)
+ .where(eq(projects.id, projectId));
+ return project;
+}
- // 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`
- )
+async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
+ const attachments = await db
+ .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`
+ )
+ );
+
+ 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);
+ }
+ }
+ }
- // 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);
- }
- }
+ return emailAttachments;
+}
+
+async function getDesignAttachments(rfqId: number) {
+ return await db
+ .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, "설계")
+ )
+ );
+}
+
+async function processVendors({
+ rfqId,
+ rfqData,
+ vendors,
+ currentUser,
+ picInfo,
+ emailAttachments,
+ designAttachments,
+ generatedPdfs
+}: {
+ rfqId: number;
+ rfqData: any;
+ vendors: any[];
+ currentUser: any;
+ picInfo: any;
+ emailAttachments: any[];
+ designAttachments: any[];
+ generatedPdfs?: any[];
+}) {
+ const results = [];
+ const errors = [];
+ const savedContracts = [];
+ const tbeSessionsCreated = [];
+
+ // PDF 저장 디렉토리 준비
+ const contractsDir = path.join(
+ process.cwd(),
+ `${process.env.NAS_PATH}`,
+ "contracts",
+ "generated"
+ );
+ await mkdir(contractsDir, { recursive: true });
+
+ // 각 벤더를 독립적으로 처리
+ for (const vendor of vendors) {
+ try {
+ const vendorResult = await db.transaction(async (tx) => {
+ return await processSingleVendor({
+ tx,
+ vendor,
+ rfqId,
+ rfqData,
+ currentUser,
+ picInfo,
+ contractsDir,
+ generatedPdfs,
+ designAttachments
+ });
+ });
+
+ results.push(vendorResult.result);
+
+ if (vendorResult.contracts) {
+ savedContracts.push(...vendorResult.contracts);
+ }
+
+ if (vendorResult.tbeSession) {
+ tbeSessionsCreated.push(vendorResult.tbeSession);
}
- // ========== TBE용 설계 문서 조회 (중요!) ==========
- const designAttachments = await tx
- .select({
- attachment: rfqLastAttachments,
- revision: rfqLastAttachmentRevisions
- })
- .from(rfqLastAttachments)
- .leftJoin(
- rfqLastAttachmentRevisions,
- and(
- eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id),
- eq(rfqLastAttachmentRevisions.isLatest, true)
- )
+ } catch (error) {
+ console.error(`벤더 ${vendor.vendorName} 처리 실패:`, error);
+
+ errors.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+
+ // 에러 발생 시 이메일 상태 업데이트
+ await updateEmailStatusFailed(rfqId, vendor.vendorId);
+ }
+ }
+
+ return { results, errors, savedContracts, tbeSessionsCreated };
+}
+
+async function processSingleVendor({
+ tx,
+ vendor,
+ rfqId,
+ rfqData,
+ currentUser,
+ picInfo,
+ contractsDir,
+ generatedPdfs,
+ designAttachments
+}: any) {
+ const isResend = vendor.isResend || false;
+ const sendVersion = (vendor.sendVersion || 0) + 1;
+
+ // 이메일 수신자 정보 준비
+ const emailRecipients = prepareEmailRecipients(vendor, picInfo.picEmail);
+
+ // RFQ Detail 처리
+ const newRfqDetail = await handleRfqDetail({
+ tx,
+ rfqId,
+ vendor,
+ currentUser,
+ emailRecipients,
+ isResend,
+ sendVersion
+ });
+
+ // PDF 계약 처리
+ const contracts = await handleContracts({
+ tx,
+ vendor,
+ generatedPdfs,
+ contractsDir,
+ newRfqDetail,
+ currentUser
+ });
+
+ // Vendor Response 처리
+ const vendorResponse = await handleVendorResponse({
+ tx,
+ rfqId,
+ vendor,
+ newRfqDetail,
+ currentUser
+ });
+
+ // TBE 세션 처리
+ const tbeSession = await handleTbeSession({
+ tx,
+ rfqId,
+ rfqData,
+ vendor,
+ newRfqDetail,
+ currentUser,
+ designAttachments
+ });
+
+ return {
+ result: {
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ success: true,
+ responseId: vendorResponse.id,
+ isResend,
+ sendVersion,
+ tbeSessionCreated: tbeSession
+ },
+ contracts,
+ tbeSession
+ };
+}
+
+function prepareEmailRecipients(vendor: any, picEmail: string) {
+ const toEmails = [vendor.selectedMainEmail];
+ const ccEmails = [...vendor.additionalEmails];
+
+ vendor.customEmails?.forEach((custom: any) => {
+ if (custom.email !== vendor.selectedMainEmail &&
+ !vendor.additionalEmails.includes(custom.email)) {
+ ccEmails.push(custom.email);
+ }
+ });
+
+ return {
+ to: toEmails,
+ cc: ccEmails,
+ sentBy: picEmail
+ };
+}
+
+async function handleRfqDetail({
+ tx,
+ rfqId,
+ vendor,
+ currentUser,
+ emailRecipients,
+ isResend,
+ sendVersion
+}: any) {
+ // 기존 detail 조회
+ const [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에는 벤더가 이미 할당되어있는 상태이어야합니다.");
+ }
+
+ // 기존 detail을 isLatest=false로 업데이트
+ 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)
+ )
+ );
+
+ // 새 detail 생성
+ const {
+ id,
+ updatedBy,
+ updatedAt,
+ isLatest,
+ sendVersion: oldSendVersion,
+ emailResentCount,
+ ...restRfqDetail
+ } = rfqDetail;
+
+ const [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();
+
+ await tx
+ .update(basicContract)
+ .set({
+ rfqCompanyId: newRfqDetail.id,
+ })
+ .where(
+ and(
+ eq(basicContract.rfqCompanyId, rfqDetail.id),
+ eq(rfqLastDetails.vendorsId, vendor.vendorId),
+ )
+ );
+
+ return newRfqDetail;
+}
+
+async function handleContracts({
+ tx,
+ vendor,
+ generatedPdfs,
+ contractsDir,
+ newRfqDetail,
+ currentUser
+}: any) {
+ if (!generatedPdfs || !vendor.contractRequirements) {
+ return [];
+ }
+
+ const savedContracts = [];
+ const vendorPdfs = generatedPdfs.filter((pdf: any) =>
+ pdf.key.startsWith(`${vendor.vendorId}_`)
+ );
+
+ for (const pdfData of vendorPdfs) {
+ // 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")
)
- .where(
- and(
- eq(rfqLastAttachments.rfqId, rfqId),
- eq(rfqLastAttachments.attachmentType, "설계") // 설계 문서만 필터링
- )
- );
+ )
+ .limit(1);
+ if (!template) {
+ console.error(`템플릿을 찾을 수 없음: ${templateName}`);
+ continue;
+ }
- // 7. 각 벤더별 처리
- const results = [];
- const errors = [];
- const savedContracts = [];
- const tbeSessionsCreated = [];
+ // 계약 생성 또는 업데이트
+ const contractRecord = await createOrUpdateContract({
+ tx,
+ template,
+ vendor,
+ newRfqDetail,
+ fileName,
+ currentUser
+ });
- const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
- await mkdir(contractsDir, { recursive: true });
+ savedContracts.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ templateName: templateName,
+ contractId: contractRecord.id,
+ fileName: fileName,
+ isUpdated: contractRecord.isUpdated
+ });
+ }
+ return savedContracts;
+}
- for (const vendor of vendors) {
- // 재발송 여부 확인
- const isResend = vendor.isResend || false;
- const sendVersion = (vendor.sendVersion || 0) + 1;
+async function createOrUpdateContract({
+ tx,
+ template,
+ vendor,
+ newRfqDetail,
+ fileName,
+ currentUser
+}: any) {
+ // 기존 계약 확인
+ 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);
- // 7.4 이메일 수신자 정보 준비
- const toEmails = [vendor.selectedMainEmail];
- const ccEmails = [...vendor.additionalEmails];
+ if (existingContract) {
+ // 업데이트
+ const [updated] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: Number(currentUser.id),
+ status: "PENDING",
+ fileName: fileName,
+ // rfqCompanyId: newRfqDetail.id,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: addDays(new Date(), 10),
+ updatedAt: new Date()
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ return { ...updated, isUpdated: true };
+ } else {
+ // 새로 생성
+ const [created] = 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();
+
+ return { ...created, isUpdated: false };
+ }
+}
- vendor.customEmails?.forEach(custom => {
- if (custom.email !== vendor.selectedMainEmail &&
- !vendor.additionalEmails.includes(custom.email)) {
- ccEmails.push(custom.email);
- }
- });
+async function handleVendorResponse({
+ tx,
+ rfqId,
+ vendor,
+ newRfqDetail,
+ currentUser
+}: any) {
+ // 기존 응답 확인
+ const existingResponses = await tx
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendor.vendorId)
+ )
+ );
- // 이메일 수신자 정보를 JSON으로 저장
- const emailRecipients = {
- to: toEmails,
- cc: ccEmails,
- sentBy: picEmail
- };
+ // 기존 응답을 isLatest=false로 업데이트
+ if (existingResponses.length > 0) {
+ await tx
+ .update(rfqLastVendorResponses)
+ .set({ isLatest: false })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.vendorId, vendor.vendorId),
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId)
+ )
+ );
+ }
- 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에는 벤더가 이미 할당되어있는 상태이어야합니다.");
- }
+ // 새 응답 생성
+ 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: newRfqDetail.currency || "USD",
+ createdBy: currentUser.id,
+ updatedBy: currentUser.id,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ .returning();
+
+ return vendorResponse;
+}
- // 기존 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 // 업데이트 여부 표시
- });
- }
- }
+async function handleTbeSession({
+ tx,
+ rfqId,
+ rfqData,
+ vendor,
+ newRfqDetail,
+ currentUser,
+ designAttachments
+}: any) {
+ // 기존 활성 TBE 세션 확인
+ const [existingActiveTbe] = await tx
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfqId),
+ eq(rfqLastTbeSessions.vendorId, vendor.vendorId),
+ sql`${rfqLastTbeSessions.status} IN ('준비중', '진행중', '검토중', '보류')`
+ )
+ );
- // 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),
- )
- )
- }
+ if (existingActiveTbe) {
+ console.log(`TBE 세션이 이미 존재함: vendor ${vendor.vendorName}`);
+ return null;
+ }
- // 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 세션 및 문서 검토 레코드 생성 끝 ==========
+ // TBE 세션 코드 생성
+ const sessionCode = await generateTbeSessionCode(tx);
+
+ // 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();
+
+ // 문서 검토 레코드 생성
+ const documentReviewsCount = await createDocumentReviews({
+ tx,
+ tbeSession,
+ designAttachments
+ });
- }
+ return {
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ sessionId: tbeSession.id,
+ sessionCode: tbeSession.sessionCode,
+ documentReviewsCount
+ };
+}
- // 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));
- }
+async function generateTbeSessionCode(tx: any) {
+ const year = new Date().getFullYear();
+ const pattern = `TBE-${year}-%`;
+
+ const [lastTbeSession] = await tx
+ .select({ sessionCode: rfqLastTbeSessions.sessionCode })
+ .from(rfqLastTbeSessions)
+ .where(like(rfqLastTbeSessions.sessionCode,pattern ))
+ .orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`)
+ .limit(1);
- return {
- success: true,
- results,
- errors,
- savedContracts,
- totalSent: results.length,
- totalFailed: errors.length,
- totalContracts: savedContracts.length
- };
+ let sessionNumber = 1;
+ if (lastTbeSession?.sessionCode) {
+ const lastNumber = parseInt(lastTbeSession.sessionCode.split('-')[2]);
+ sessionNumber = isNaN(lastNumber) ? 1 : lastNumber + 1;
+ }
+
+ return `TBE-${year}-${String(sessionNumber).padStart(3, '0')}`;
+}
+
+async function createDocumentReviews({
+ tx,
+ tbeSession,
+ designAttachments
+}: any) {
+ let documentReviewsCount = 0;
+
+ for (const { attachment, revision } of designAttachments) {
+ await tx
+ .insert(rfqLastTbeDocumentReviews)
+ .values({
+ tbeSessionId: tbeSession.id,
+ documentSource: "buyer",
+ buyerAttachmentId: attachment.id,
+ buyerAttachmentRevisionId: revision?.id || null,
+ vendorAttachmentId: 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()
});
- return result;
+ documentReviewsCount++;
+ }
+
+ return documentReviewsCount;
+}
+async function updateEmailStatusFailed(rfqId: number, vendorId: number) {
+ try {
+ await db
+ .update(rfqLastDetails)
+ .set({
+ emailStatus: "failed",
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
} catch (error) {
- console.error("RFQ 발송 실패:", error);
- throw new Error(
- error instanceof Error
- ? error.message
- : "RFQ 발송 중 오류가 발생했습니다."
- );
+ console.error("이메일 상태 업데이트 실패:", error);
}
}
+async function updateRfqStatus(rfqId: number, userId: number) {
+ await db
+ .update(rfqsLast)
+ .set({
+ status: "RFQ 발송",
+ rfqSendDate: new Date(),
+ sentBy: Number(userId),
+ updatedBy: Number(userId),
+ updatedAt: new Date()
+ })
+ .where(eq(rfqsLast.id, rfqId));
+ }
+
export async function updateRfqDueDate(
rfqId: number,
newDueDate: Date | string,
diff --git a/lib/rfq-last/vendor-response/participation-dialog.tsx b/lib/rfq-last/vendor-response/participation-dialog.tsx
index a7337ac2..3923872e 100644
--- a/lib/rfq-last/vendor-response/participation-dialog.tsx
+++ b/lib/rfq-last/vendor-response/participation-dialog.tsx
@@ -73,7 +73,7 @@ export function ParticipationDialog({
title: "참여 확정",
description: result.message,
})
- // router.push(`/partners/rfq-last/${rfqId}`)
+ router.push(`/partners/rfq-last/${rfqId}`)
router.refresh()
} else {
toast({
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index 619ea749..ce97dcde 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -1422,7 +1422,7 @@ export function SendRfqDialog({
[vendor.vendorId]: !checked
}));
}}
- className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
/>
<span className="text-xs text-yellow-800">
{skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"}
diff --git a/package-lock.json b/package-lock.json
index e20c6b78..cd13fae3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -170,6 +170,7 @@
"ua-parser-js": "^2.0.4",
"uuid": "^11.0.5",
"vaul": "^1.1.2",
+ "xml2js": "^0.6.2",
"zod": "^3.24.1"
},
"devDependencies": {
diff --git a/package.json b/package.json
index 49fd3a4f..6c3aec93 100644
--- a/package.json
+++ b/package.json
@@ -173,6 +173,7 @@
"ua-parser-js": "^2.0.4",
"uuid": "^11.0.5",
"vaul": "^1.1.2",
+ "xml2js": "^0.6.2",
"zod": "^3.24.1"
},
"devDependencies": {