summaryrefslogtreecommitdiff
path: root/lib/b-rfq/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
commit7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 (patch)
treedaa214d404c7fc78b32419a028724e5671a6c7a4 /lib/b-rfq/service.ts
parentfa6a6093014c5d60188edfc9c4552e81c4b97bd1 (diff)
(대표님) 20250617 18시 작업사항
Diffstat (limited to 'lib/b-rfq/service.ts')
-rw-r--r--lib/b-rfq/service.ts1558
1 files changed, 1464 insertions, 94 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
index 0dc61832..c5398e6c 100644
--- a/lib/b-rfq/service.ts
+++ b/lib/b-rfq/service.ts
@@ -1,16 +1,24 @@
'use server'
-import { revalidateTag, unstable_cache } from "next/cache"
-import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
+import { revalidateTag, unstable_cache ,unstable_noStore} from "next/cache"
+import {count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
import db from "@/db/db"
-import { Incoterm, RfqDashboardView, Vendor, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
+import { vendorResponseDetailView,
+ attachmentRevisionHistoryView,
+ rfqProgressSummaryView,
+ vendorResponseAttachmentsEnhanced ,Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
+ vendorResponseAttachmentsB} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
import { rfqDashboardView } from "@/db/schema" // 뷰 import
import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations"
+import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema } from "./validations"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { unlink } from "fs/promises"
+import { getErrorMessage } from "../handle-error"
+import { AddInitialRfqFormData } from "./initial/add-initial-rfq-dialog"
+import { sendEmail } from "../mail/sendEmail"
+import { RfqType } from "../rfqs/validations"
const tag = {
initialRfqDetail:"initial-rfq",
@@ -25,8 +33,7 @@ const tag = {
} as const;
export async function getRFQDashboard(input: GetRFQDashboardSchema) {
- return unstable_cache(
- async () => {
+
try {
const offset = (input.page - 1) * input.perPage;
@@ -140,11 +147,7 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) {
console.error("Error in getRFQDashboard:", err);
return { data: [], pageCount: 0, total: 0 };
}
- },
- [JSON.stringify(input)],
- { revalidate: 3600, tags: [tag.rfqDashboard] },
-
- )();
+
}
// 헬퍼 함수들
@@ -192,6 +195,7 @@ async function generateNextSerial(picCode: string): Promise<string> {
return "00001" // 기본값
}
}
+
export async function createRfqAction(input: CreateRfqInput) {
try {
// 입력 데이터 검증
@@ -308,8 +312,6 @@ export async function getRfqAttachments(
input: GetRfqAttachmentsSchema,
rfqId: number
) {
- return unstable_cache(
- async () => {
try {
const offset = (input.page - 1) * input.perPage
@@ -445,10 +447,7 @@ export async function getRfqAttachments(
console.error("getRfqAttachments error:", err)
return { data: [], pageCount: 0 }
}
- },
- [JSON.stringify(input), `${rfqId}`],
- { revalidate: 300, tags: [tag.rfqAttachments(rfqId)] },
- )()
+
}
// 첨부파일별 벤더 응답 통계 조회
@@ -495,51 +494,79 @@ export async function getVendorResponsesForAttachment(
attachmentId: number,
rfqType: 'INITIAL' | 'FINAL' = 'INITIAL'
) {
- return unstable_cache(
- async () => {
- try {
- const responses = await db
- .select({
- id: vendorAttachmentResponses.id,
- attachmentId: vendorAttachmentResponses.attachmentId,
- vendorId: vendorAttachmentResponses.vendorId,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName,
- vendorCountry: vendors.country,
- rfqType: vendorAttachmentResponses.rfqType,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- responseStatus: vendorAttachmentResponses.responseStatus,
- currentRevision: vendorAttachmentResponses.currentRevision,
- respondedRevision: vendorAttachmentResponses.respondedRevision,
- responseComment: vendorAttachmentResponses.responseComment,
- vendorComment: vendorAttachmentResponses.vendorComment,
- requestedAt: vendorAttachmentResponses.requestedAt,
- respondedAt: vendorAttachmentResponses.respondedAt,
- updatedAt: vendorAttachmentResponses.updatedAt,
- })
- .from(vendorAttachmentResponses)
- .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id))
- .where(
- and(
- eq(vendorAttachmentResponses.attachmentId, attachmentId),
- eq(vendorAttachmentResponses.rfqType, rfqType)
- )
- )
- .orderBy(vendors.vendorName)
-
- return responses
- } catch (err) {
- console.error("getVendorResponsesForAttachment error:", err)
- return []
- }
- },
- [`${attachmentId}`, rfqType],
- { revalidate: 180, tags: [tag.vendorResponses(attachmentId, rfqType)] },
+ try {
+ // 1. 기본 벤더 응답 정보 가져오기
+ const responses = await db
+ .select({
+ id: vendorAttachmentResponses.id,
+ attachmentId: vendorAttachmentResponses.attachmentId,
+ vendorId: vendorAttachmentResponses.vendorId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorCountry: vendors.country,
+ rfqType: vendorAttachmentResponses.rfqType,
+ rfqRecordId: vendorAttachmentResponses.rfqRecordId,
+ responseStatus: vendorAttachmentResponses.responseStatus,
+ currentRevision: vendorAttachmentResponses.currentRevision,
+ respondedRevision: vendorAttachmentResponses.respondedRevision,
+ responseComment: vendorAttachmentResponses.responseComment,
+ vendorComment: vendorAttachmentResponses.vendorComment,
+ // 새로 추가된 필드들
+ revisionRequestComment: vendorAttachmentResponses.revisionRequestComment,
+ revisionRequestedAt: vendorAttachmentResponses.revisionRequestedAt,
+ requestedAt: vendorAttachmentResponses.requestedAt,
+ respondedAt: vendorAttachmentResponses.respondedAt,
+ updatedAt: vendorAttachmentResponses.updatedAt,
+ })
+ .from(vendorAttachmentResponses)
+ .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id))
+ .where(
+ and(
+ eq(vendorAttachmentResponses.attachmentId, attachmentId),
+ eq(vendorAttachmentResponses.rfqType, rfqType)
+ )
+ )
+ .orderBy(vendors.vendorName);
+
+ // 2. 각 응답에 대한 파일 정보 가져오기
+ const responseIds = responses.map(r => r.id);
+
+ let responseFiles: any[] = [];
+ if (responseIds.length > 0) {
+ responseFiles = await db
+ .select({
+ id: vendorResponseAttachmentsB.id,
+ vendorResponseId: vendorResponseAttachmentsB.vendorResponseId,
+ fileName: vendorResponseAttachmentsB.fileName,
+ originalFileName: vendorResponseAttachmentsB.originalFileName,
+ filePath: vendorResponseAttachmentsB.filePath,
+ fileSize: vendorResponseAttachmentsB.fileSize,
+ fileType: vendorResponseAttachmentsB.fileType,
+ description: vendorResponseAttachmentsB.description,
+ uploadedAt: vendorResponseAttachmentsB.uploadedAt,
+ })
+ .from(vendorResponseAttachmentsB)
+ .where(inArray(vendorResponseAttachmentsB.vendorResponseId, responseIds))
+ .orderBy(desc(vendorResponseAttachmentsB.uploadedAt));
+ }
- )()
+ // 3. 응답에 파일 정보 병합
+ const enhancedResponses = responses.map(response => ({
+ ...response,
+ files: responseFiles.filter(file => file.vendorResponseId === response.id),
+ totalFiles: responseFiles.filter(file => file.vendorResponseId === response.id).length,
+ latestFile: responseFiles
+ .filter(file => file.vendorResponseId === response.id)
+ .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())[0] || null,
+ }));
+
+ return enhancedResponses;
+ } catch (err) {
+ console.error("getVendorResponsesForAttachment error:", err);
+ return [];
+ }
}
-
export async function confirmDocuments(rfqId: number) {
try {
const session = await getServerSession(authOptions)
@@ -650,10 +677,7 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
}
})
- // 캐시 무효화
- revalidateTag(`rfq-${rfqId}`)
- revalidateTag(`rfq-attachments-${rfqId}`)
-
+
const attachmentCount = targetAttachments.length
const attachmentList = targetAttachments
.map(a => `${a.serialNo} (${a.currentRevision})`)
@@ -738,7 +762,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) {
filePath: validatedRecord.filePath,
fileSize: validatedRecord.fileSize,
fileType: validatedRecord.fileType,
- revisionComment: validatedRecord.revisionComment || "초기 업로드",
+ revisionComment: validatedRecord.revisionComment,
isLatest: true,
createdBy: userId,
})
@@ -756,11 +780,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) {
return { attachment, revision }
})
- revalidateTag(tag.rfq(validatedRecord.rfqId));
- revalidateTag(tag.rfqDashboard);
- revalidateTag(tag.rfqAttachments(validatedRecord.rfqId));
-
- return {
+ return {
success: true,
message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`,
attachment: result.attachment,
@@ -868,13 +888,7 @@ export async function addRevisionToAttachment(
return inserted;
});
- // ────────────────────────────────────────────────────────────────────────────
- // 6. 캐시 무효화 (rfqId 기준으로 수정)
- // ────────────────────────────────────────────────────────────────────────────
- revalidateTag(tag.rfq(rfqId));
- revalidateTag(tag.rfqDashboard);
- revalidateTag(tag.rfqAttachments(rfqId));
- revalidateTag(tag.attachmentRevisions(attachmentId));
+
return {
success: true,
@@ -892,8 +906,7 @@ export async function addRevisionToAttachment(
// 특정 첨부파일의 모든 리비전 조회
export async function getAttachmentRevisions(attachmentId: number) {
- return unstable_cache(
- async () => {
+
try {
const revisions = await db
.select({
@@ -927,11 +940,6 @@ export async function getAttachmentRevisions(attachmentId: number) {
revisions: [],
}
}
- },
- [`${attachmentId}`],
- { revalidate: 180, tags: [tag.attachmentRevisions(attachmentId)] },
-
- )()
}
@@ -999,11 +1007,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
}
})
- // 캐시 무효화
- result.rfqIds.forEach(rfqId => {
- revalidateTag(`rfq-attachments-${rfqId}`)
- })
-
+
return {
success: true,
message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`,
@@ -1025,8 +1029,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
//Initial RFQ
export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
- return unstable_cache(
- async () => {
+
try {
const offset = (input.page - 1) * input.perPage;
@@ -1112,6 +1115,7 @@ export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqI
return { data: [], pageCount: 0, total: 0 };
}
+ console.log(totalResult);
console.log(total);
// 7) 정렬 및 페이징 처리된 데이터 조회
@@ -1139,10 +1143,6 @@ export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqI
console.error("Error in getInitialRfqDetail:", err);
return { data: [], pageCount: 0, total: 0 };
}
- },
- [JSON.stringify(input)],
- { revalidate: 3600, tags: [tag.initialRfqDetail] },
- )();
}
export async function getVendorsForSelection() {
@@ -1152,6 +1152,7 @@ export async function getVendorsForSelection() {
id: vendors.id,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
country: vendors.country,
status: vendors.status,
})
@@ -1179,6 +1180,8 @@ export async function getVendorsForSelection() {
export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
try {
+ console.log('Incoming data:', data);
+
const [newRecord] = await db
.insert(initialRfq)
.values({
@@ -1231,4 +1234,1371 @@ export async function getIncotermsForSelection() {
console.error("Error fetching incoterms:", error)
throw new Error("Failed to fetch incoterms")
}
+}
+
+export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) {
+ unstable_noStore ()
+ try {
+ const { ids } = removeInitialRfqsSchema.parse(input)
+
+ await db.transaction(async (tx) => {
+ await tx.delete(initialRfq).where(inArray(initialRfq.id, ids))
+ })
+
+ revalidateTag(tag.initialRfqDetail)
+
+ return {
+ data: null,
+ error: null,
+ }
+ } catch (err) {
+ return {
+ data: null,
+ error: getErrorMessage(err),
+ }
+ }
+}
+
+interface ModifyInitialRfqInput extends UpdateInitialRfqSchema {
+ id: number
+}
+
+export async function modifyInitialRfq(input: ModifyInitialRfqInput) {
+ unstable_noStore ()
+ try {
+ const { id, ...updateData } = input
+
+ // validation
+ updateInitialRfqSchema.parse(updateData)
+
+ await db.transaction(async (tx) => {
+ const existingRfq = await tx
+ .select()
+ .from(initialRfq)
+ .where(eq(initialRfq.id, id))
+ .limit(1)
+
+ if (existingRfq.length === 0) {
+ throw new Error("초기 RFQ를 찾을 수 없습니다.")
+ }
+
+ await tx
+ .update(initialRfq)
+ .set({
+ ...updateData,
+ // Convert empty strings to null for optional fields
+ incotermsCode: updateData.incotermsCode || null,
+ gtc: updateData.gtc || null,
+ gtcValidDate: updateData.gtcValidDate || null,
+ classification: updateData.classification || null,
+ sparepart: updateData.sparepart || null,
+ validDate: updateData.validDate || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(initialRfq.id, id))
+ })
+
+ revalidateTag(tag.initialRfqDetail)
+
+ return {
+ data: null,
+ error: null,
+ }
+ } catch (err) {
+ return {
+ data: null,
+ error: getErrorMessage(err),
+ }
+ }
+}
+
+
+
+
+// 이메일 발송용 데이터 타입
+interface EmailData {
+ rfqCode: string
+ projectName: string
+ projectCompany: string
+ projectFlag: string
+ projectSite: string
+ classification: string
+ incotermsCode: string
+ incotermsDescription: string
+ dueDate: string
+ validDate: string
+ sparepart: string
+ vendorName: string
+ picName: string
+ picEmail: string
+ warrantyPeriod: string
+ packageName: string
+ rfqRevision: number
+ emailType: string
+}
+
+export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
+ unstable_noStore()
+ try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const { initialRfqIds, language } = bulkEmailSchema.parse(input)
+
+ // 1. 선택된 초기 RFQ들의 상세 정보 조회
+ const initialRfqDetails = await db
+ .select({
+ // initialRfqDetailView 필드들을 명시적으로 선택
+ rfqId: initialRfqDetailView.rfqId,
+ rfqCode: initialRfqDetailView.rfqCode,
+ rfqStatus: initialRfqDetailView.rfqStatus,
+ initialRfqId: initialRfqDetailView.initialRfqId,
+ initialRfqStatus: initialRfqDetailView.initialRfqStatus,
+ vendorId: initialRfqDetailView.vendorId,
+ vendorCode: initialRfqDetailView.vendorCode,
+ vendorName: initialRfqDetailView.vendorName,
+ vendorCategory: initialRfqDetailView.vendorCategory,
+ vendorCountry: initialRfqDetailView.vendorCountry,
+ vendorBusinessSize: initialRfqDetailView.vendorBusinessSize,
+ dueDate: initialRfqDetailView.dueDate,
+ validDate: initialRfqDetailView.validDate,
+ incotermsCode: initialRfqDetailView.incotermsCode,
+ incotermsDescription: initialRfqDetailView.incotermsDescription,
+ shortList: initialRfqDetailView.shortList,
+ returnYn: initialRfqDetailView.returnYn,
+ cpRequestYn: initialRfqDetailView.cpRequestYn,
+ prjectGtcYn: initialRfqDetailView.prjectGtcYn,
+ returnRevision: initialRfqDetailView.returnRevision,
+ rfqRevision: initialRfqDetailView.rfqRevision,
+ gtc: initialRfqDetailView.gtc,
+ gtcValidDate: initialRfqDetailView.gtcValidDate,
+ classification: initialRfqDetailView.classification,
+ sparepart: initialRfqDetailView.sparepart,
+ createdAt: initialRfqDetailView.createdAt,
+ updatedAt: initialRfqDetailView.updatedAt,
+ // bRfqs에서 추가로 필요한 필드들
+ picName: bRfqs.picName,
+ picCode: bRfqs.picCode,
+ packageName: bRfqs.packageName,
+ packageNo: bRfqs.packageNo,
+ projectCompany: bRfqs.projectCompany,
+ projectFlag: bRfqs.projectFlag,
+ projectSite: bRfqs.projectSite,
+ })
+ .from(initialRfqDetailView)
+ .leftJoin(bRfqs, eq(initialRfqDetailView.rfqId, bRfqs.id))
+ .where(inArray(initialRfqDetailView.initialRfqId, initialRfqIds))
+
+ if (initialRfqDetails.length === 0) {
+ return {
+ success: false,
+ message: "선택된 초기 RFQ를 찾을 수 없습니다.",
+ }
+ }
+
+ // 2. 각 RFQ에 대한 첨부파일 조회
+ const rfqIds = [...new Set(initialRfqDetails.map(rfq => rfq.rfqId))].filter((id): id is number => id !== null)
+ const attachments = await db
+ .select()
+ .from(bRfqsAttachments)
+ .where(inArray(bRfqsAttachments.rfqId, rfqIds))
+
+ // 3. 벤더 이메일 정보 조회 (모든 이메일 주소 포함)
+ const vendorIds = [...new Set(initialRfqDetails.map(rfq => rfq.vendorId))].filter((id): id is number => id !== null)
+ const vendorsWithAllEmails = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ representativeEmail: vendors.representativeEmail,
+ // 연락처 이메일들을 JSON 배열로 집계
+ contactEmails: sql<string[]>`
+ COALESCE(
+ (SELECT json_agg(contact_email)
+ FROM vendor_contacts
+ WHERE vendor_id = ${vendors.id}
+ AND contact_email IS NOT NULL
+ AND contact_email != ''
+ ),
+ '[]'::json
+ )
+ `.as("contact_emails")
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds))
+
+ // 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수
+ function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] {
+ const emails: string[] = []
+
+ // 벤더 기본 이메일
+ if (vendor.email) {
+ emails.push(vendor.email)
+ }
+
+ // 대표자 이메일
+ if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) {
+ emails.push(vendor.representativeEmail)
+ }
+
+ // 연락처 이메일들
+ if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) {
+ vendor.contactEmails.forEach(contactEmail => {
+ if (contactEmail && !emails.includes(contactEmail)) {
+ emails.push(contactEmail)
+ }
+ })
+ }
+
+ return emails.filter(email => email && email.trim() !== '')
+ }
+
+ const results = []
+ const errors = []
+
+ // 4. 각 초기 RFQ에 대해 처리
+ for (const rfqDetail of initialRfqDetails) {
+ try {
+ // vendorId null 체크
+ if (!rfqDetail.vendorId) {
+ errors.push(`벤더 ID가 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
+ continue
+ }
+
+ // 해당 RFQ의 첨부파일들
+ const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId)
+
+ // 벤더 정보
+ const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId)
+ if (!vendor) {
+ errors.push(`벤더 정보를 찾을 수 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
+ continue
+ }
+
+ // 해당 벤더의 모든 이메일 주소 수집
+ const vendorEmails = getAllVendorEmails(vendor)
+
+ if (vendorEmails.length === 0) {
+ errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`)
+ continue
+ }
+
+ // 5. 기존 vendorAttachmentResponses 조회하여 리비전 상태 확인
+ const currentRfqRevision = rfqDetail.rfqRevision || 0
+ let emailType: "NEW" | "RESEND" | "REVISION" = "NEW"
+ let revisionToUse = currentRfqRevision
+
+ // 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용)
+ if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
+ const existingResponses = await db
+ .select()
+ .from(vendorAttachmentResponses)
+ .where(
+ and(
+ eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
+ eq(vendorAttachmentResponses.rfqType, "INITIAL"),
+ eq(vendorAttachmentResponses.rfqRecordId, rfqDetail.initialRfqId)
+ )
+ )
+
+ if (existingResponses.length > 0) {
+ // 기존 응답이 있음
+ const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0")
+
+ if (currentRfqRevision > existingRevision) {
+ // RFQ 리비전이 올라감 → 리비전 업데이트
+ emailType = "REVISION"
+ revisionToUse = currentRfqRevision
+ } else {
+ // 동일하거나 낮음 → 재전송
+ emailType = "RESEND"
+ revisionToUse = existingRevision
+ }
+ } else {
+ // 기존 응답이 없음 → 신규 전송
+ emailType = "NEW"
+ revisionToUse = currentRfqRevision
+ }
+ }
+
+ // 6. vendorAttachmentResponses 레코드 생성/업데이트
+ for (const attachment of rfqAttachments) {
+ const existingResponse = await db
+ .select()
+ .from(vendorAttachmentResponses)
+ .where(
+ and(
+ eq(vendorAttachmentResponses.attachmentId, attachment.id),
+ eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
+ eq(vendorAttachmentResponses.rfqType, "INITIAL")
+ )
+ )
+ .limit(1)
+
+ if (existingResponse.length === 0) {
+ // 새 응답 레코드 생성
+ await db.insert(vendorAttachmentResponses).values({
+ attachmentId: attachment.id,
+ vendorId: rfqDetail.vendorId,
+ rfqType: "INITIAL",
+ rfqRecordId: rfqDetail.initialRfqId,
+ responseStatus: "NOT_RESPONDED",
+ currentRevision: `Rev.${revisionToUse}`,
+ requestedAt: new Date(),
+ })
+ } else {
+ // 기존 레코드 업데이트
+ await db
+ .update(vendorAttachmentResponses)
+ .set({
+ currentRevision: `Rev.${revisionToUse}`,
+ requestedAt: new Date(),
+ // 리비전 업데이트인 경우 응답 상태 초기화
+ responseStatus: emailType === "REVISION" ? "NOT_RESPONDED" : existingResponse[0].responseStatus,
+ })
+ .where(eq(vendorAttachmentResponses.id, existingResponse[0].id))
+ }
+
+ }
+
+ const formatDateSafely = (date: Date | string | null | undefined): string => {
+ if (!date) return ""
+ try {
+ // Date 객체로 변환하고 포맷팅
+ const dateObj = new Date(date)
+ // 유효한 날짜인지 확인
+ if (isNaN(dateObj.getTime())) return ""
+
+ return dateObj.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })
+ } catch (error) {
+ console.error("Date formatting error:", error)
+ return ""
+ }
+ }
+
+ // 7. 이메일 발송
+ const emailData: EmailData = {
+ name:vendor.vendorName,
+ rfqCode: rfqDetail.rfqCode || "",
+ projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용
+ projectCompany: rfqDetail.projectCompany || "",
+ projectFlag: rfqDetail.projectFlag || "",
+ projectSite: rfqDetail.projectSite || "",
+ classification: rfqDetail.classification || "ABS",
+ incotermsCode: rfqDetail.incotermsCode || "FOB",
+ incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port",
+ dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "",
+ validDate: rfqDetail.validDate ?formatDateSafely(rfqDetail.validDate) : "",
+ sparepart: rfqDetail.sparepart || "One(1) year operational spare parts",
+ vendorName: vendor.vendorName,
+ picName: session.user.name || rfqDetail.picName || "Procurement Manager",
+ picEmail: session.user.email || "procurement@samsung.com",
+ warrantyPeriod: "Refer to commercial package attached",
+ packageName: rfqDetail.packageName || "",
+ rfqRevision: revisionToUse, // 리비전 정보 추가
+ emailType: emailType, // 이메일 타입 추가
+ }
+
+ // 이메일 제목 생성 (타입에 따라 다르게)
+ let emailSubject = ""
+ const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : ""
+
+ switch (emailType) {
+ case "NEW":
+ emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
+ break
+ case "RESEND":
+ emailSubject = `[SHI RFQ - RESEND] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
+ break
+ case "REVISION":
+ emailSubject = `[SHI RFQ - REVISED] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
+ break
+ }
+
+ // nodemailer로 모든 이메일 주소에 한번에 발송
+ await sendEmail({
+ // from: session.user.email || undefined,
+ to: vendorEmails.join(", "), // 콤마+공백으로 구분
+ subject: emailSubject,
+ template: "initial-rfq-invitation", // hbs 템플릿 파일명
+ context: {
+ ...emailData,
+ language,
+ }
+ })
+
+ // 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용)
+ if(rfqDetail.initialRfqId && rfqDetail.rfqId){
+ // Promise.all로 두 테이블 동시 업데이트
+ await Promise.all([
+ // initialRfq 테이블 업데이트
+ db
+ .update(initialRfq)
+ .set({
+ initialRfqStatus: "Init. RFQ Sent",
+ updatedAt: new Date(),
+ })
+ .where(eq(initialRfq.id, rfqDetail.initialRfqId)),
+
+ // bRfqs 테이블 status도 함께 업데이트
+ db
+ .update(bRfqs)
+ .set({
+ status: "Init. RFQ Sent",
+ // updatedBy: session.user.id,
+ updatedAt: new Date(),
+ })
+ .where(eq(bRfqs.id, rfqDetail.rfqId))
+ ]);
+ }
+
+ results.push({
+ initialRfqId: rfqDetail.initialRfqId,
+ vendorName: vendor.vendorName,
+ vendorEmails: vendorEmails, // 발송된 모든 이메일 주소 기록
+ emailCount: vendorEmails.length,
+ emailType: emailType,
+ rfqRevision: revisionToUse,
+ success: true,
+ })
+
+ } catch (error) {
+ console.error(`Error processing RFQ ${rfqDetail.initialRfqId}:`, error)
+ errors.push(`RFQ ${rfqDetail.initialRfqId} 처리 중 오류: ${getErrorMessage(error)}`)
+ }
+ }
+
+ // 9. 페이지 새로고침
+ revalidateTag(tag.initialRfqDetail)
+ revalidateTag(tag.rfqDashboard) // 📋 RFQ 대시보드도 새로고침
+
+
+ return {
+ success: true,
+ message: `${results.length}개의 RFQ 이메일이 발송되었습니다.`,
+ results,
+ errors: errors.length > 0 ? errors : undefined,
+ }
+
+ } catch (err) {
+ console.error("Bulk email error:", err)
+ return {
+ success: false,
+ message: getErrorMessage(err),
+ }
+ }
+}
+
+// 개별 RFQ 이메일 재발송
+export async function resendInitialRfqEmail(initialRfqId: number) {
+ unstable_noStore()
+ try {
+ const result = await sendBulkInitialRfqEmails({
+ initialRfqIds: [initialRfqId],
+ language: "en",
+ })
+
+ return result
+ } catch (err) {
+ return {
+ success: false,
+ message: getErrorMessage(err),
+ }
+ }
+}
+
+export type VendorResponseDetail = VendorAttachmentResponse & {
+ attachment: {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ description: string | null;
+ currentRevision: string;
+ };
+ vendor: {
+ id: number;
+ vendorCode: string;
+ vendorName: string;
+ country: string | null;
+ businessSize: string | null;
+ };
+ rfq: {
+ id: number;
+ rfqCode: string | null;
+ description: string | null;
+ status: string;
+ dueDate: Date;
+ };
+};
+
+export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) {
+ try {
+ // 페이지네이션 설정
+ const page = input.page || 1;
+ const perPage = input.perPage || 10;
+ const offset = (page - 1) * perPage;
+
+ // 기본 조건
+ let whereConditions = [];
+
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
+
+ // RFQ 타입 조건
+ // if (input.rfqType !== "ALL") {
+ // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
+ // }
+
+ // 날짜 범위 조건
+ if (input.from && input.to) {
+ whereConditions.push(
+ and(
+ gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
+ lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
+ )
+ );
+ }
+
+ const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 그룹핑된 응답 요약 데이터 조회
+ const groupedResponses = await db
+ .select({
+ vendorId: vendorAttachmentResponses.vendorId,
+ rfqRecordId: vendorAttachmentResponses.rfqRecordId,
+ rfqType: vendorAttachmentResponses.rfqType,
+
+ // 통계 계산 (조건부 COUNT 수정)
+ totalAttachments: count(),
+ respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+
+ // 날짜 정보
+ requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
+ lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
+
+ // 코멘트 여부
+ hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere)
+ .groupBy(
+ vendorAttachmentResponses.vendorId,
+ vendorAttachmentResponses.rfqRecordId,
+ vendorAttachmentResponses.rfqType
+ )
+ .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
+ .offset(offset)
+ .limit(perPage);
+
+ // 벤더 정보와 RFQ 정보를 별도로 조회
+ const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
+ const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
+
+ // 벤더 정보 조회
+ const vendorsData = await db.query.vendors.findMany({
+ where: or(...vendorIds.map(id => eq(vendors.id, id))),
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ });
+
+ // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
+ const [initialRfqs] = await Promise.all([
+ db.query.initialRfq.findMany({
+ where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
+ with: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ }
+ }
+ }
+ })
+
+ ]);
+
+ // 데이터 조합 및 변환
+ const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
+ const vendor = vendorsData.find(v => v.id === response.vendorId);
+
+ let rfqInfo = null;
+ if (response.rfqType === "INITIAL") {
+ const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
+ rfqInfo = initialRfq?.rfq || null;
+ }
+
+ // 응답률 계산
+ const responseRate = Number(response.totalAttachments) > 0
+ ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 완료율 계산 (응답완료 + 포기)
+ const completionRate = Number(response.totalAttachments) > 0
+ ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 전체 상태 결정
+ let overallStatus: ResponseStatus = "NOT_RESPONDED";
+ if (Number(response.revisionRequestedCount) > 0) {
+ overallStatus = "REVISION_REQUESTED";
+ } else if (completionRate === 100) {
+ overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
+ } else if (Number(response.respondedCount) > 0) {
+ overallStatus = "RESPONDED"; // 부분 응답
+ }
+
+ return {
+ id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
+ vendorId: response.vendorId,
+ rfqRecordId: response.rfqRecordId,
+ rfqType: response.rfqType,
+ rfq: rfqInfo,
+ vendor: vendor || null,
+ totalAttachments: Number(response.totalAttachments),
+ respondedCount: Number(response.respondedCount),
+ pendingCount: Number(response.pendingCount),
+ revisionRequestedCount: Number(response.revisionRequestedCount),
+ waivedCount: Number(response.waivedCount),
+ responseRate,
+ completionRate,
+ overallStatus,
+ requestedAt: response.requestedAt,
+ lastRespondedAt: response.lastRespondedAt,
+ hasComments: response.hasComments,
+ };
+ });
+
+ // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
+ const totalCountResult = await db
+ .select({
+ totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere);
+
+ const totalCount = Number(totalCountResult[0].totalCount);
+ const pageCount = Math.ceil(totalCount / perPage);
+
+ return {
+ data: transformedResponses,
+ pageCount,
+ totalCount
+ };
+
+ } catch (err) {
+ console.error("getVendorRfqResponses 에러:", err);
+ return { data: [], pageCount: 0, totalCount: 0 };
+ }
+}
+/**
+ * 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용)
+ */
+export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) {
+ try {
+ // 해당 RFQ의 모든 첨부파일 응답 조회
+ const responses = await db.query.vendorAttachmentResponses.findMany({
+ where: and(
+ eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
+ eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
+ ),
+ with: {
+ attachment: {
+ with: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ // 추가 정보
+ picCode: true,
+ picName: true,
+ EngPicName: true,
+ packageNo: true,
+ packageName: true,
+ projectId: true,
+ projectCompany: true,
+ projectFlag: true,
+ projectSite: true,
+ remark: true,
+ },
+ with: {
+ project: {
+ columns: {
+ id: true,
+ code: true,
+ name: true,
+ type: true,
+ }
+ }
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ },
+ responseAttachments: true,
+ },
+ orderBy: [asc(vendorAttachmentResponses.attachmentId)]
+ });
+
+ return {
+ data: responses,
+ rfqInfo: responses[0]?.attachment?.rfq || null,
+ vendorInfo: responses[0]?.vendor || null,
+ };
+
+ } catch (err) {
+ console.error("getRfqAttachmentResponses 에러:", err);
+ return { data: [], rfqInfo: null, vendorInfo: null };
+ }
+}
+
+export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) {
+ try {
+ const initial: Record<ResponseStatus, number> = {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ };
+
+ // 조건 설정
+ let whereConditions = [];
+
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
+
+ // RFQ ID 조건
+ if (rfqId) {
+ const attachmentIds = await db
+ .select({ id: bRfqsAttachments.id })
+ .from(bRfqsAttachments)
+ .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
+
+ if (attachmentIds.length > 0) {
+ whereConditions.push(
+ or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ );
+ }
+ }
+
+ // RFQ 타입 조건
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
+
+ const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 상태별 그룹핑 쿼리
+ const rows = await db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus);
+
+ // 결과 처리
+ const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
+ }
+ return acc;
+ }, initial);
+
+ return result;
+ } catch (err) {
+ console.error("getVendorResponseStatusCounts 에러:", err);
+ return {} as Record<ResponseStatus, number>;
+ }
+}
+
+/**
+ * RFQ별 벤더 응답 요약 조회
+ */
+export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
+
+ try {
+ // RFQ의 첨부파일 목록 조회 (relations 사용)
+ const attachments = await db.query.bRfqsAttachments.findMany({
+ where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
+ columns: {
+ id: true,
+ attachmentType: true,
+ serialNo: true,
+ description: true,
+ }
+ });
+
+ if (attachments.length === 0) {
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
+
+ // 조건 설정
+ let whereConditions = [
+ or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ ];
+
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
+
+ const whereCondition = and(...whereConditions);
+
+ // 벤더 수 및 응답 통계 조회
+ const [vendorStats, statusCounts] = await Promise.all([
+ // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
+ db
+ .select({
+ totalVendors: count(),
+ respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition),
+
+ // 상태별 개수
+ db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus)
+ ]);
+
+ const stats = vendorStats[0];
+ const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
+ }
+ return acc;
+ }, {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ });
+
+ const responseRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ const completionRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ return {
+ totalAttachments: attachments.length,
+ totalVendors: Number(stats.totalVendors),
+ responseRate,
+ completionRate,
+ statusCounts: statusCountsMap
+ };
+
+ } catch (err) {
+ console.error("getRfqResponseSummary 에러:", err);
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
+}
+
+/**
+ * 벤더별 응답 진행률 조회
+ */
+export async function getVendorResponseProgress(vendorId: string) {
+
+ try {
+ let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
+
+ const whereCondition = and(...whereConditions);
+
+ const progress = await db
+ .select({
+ totalRequests: count(),
+ responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition);
+ console.log(progress,"progress")
+
+ const stats = progress[0];
+ const responseRate = Number(stats.totalRequests) > 0
+ ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ const completionRate = Number(stats.totalRequests) > 0
+ ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ return {
+ totalRequests: Number(stats.totalRequests),
+ responded: Number(stats.responded),
+ pending: Number(stats.pending),
+ revisionRequested: Number(stats.revisionRequested),
+ waived: Number(stats.waived),
+ responseRate,
+ completionRate,
+ };
+
+ } catch (err) {
+ console.error("getVendorResponseProgress 에러:", err);
+ return {
+ totalRequests: 0,
+ responded: 0,
+ pending: 0,
+ revisionRequested: 0,
+ waived: 0,
+ responseRate: 0,
+ completionRate: 0,
+ };
+ }
+}
+
+
+export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, rfqRecordId: string) {
+ try {
+ // 1. 벤더 응답 상세 정보 조회 (뷰 사용)
+ const responses = await db
+ .select()
+ .from(vendorResponseDetailView)
+ .where(
+ and(
+ eq(vendorResponseDetailView.vendorId, Number(vendorId)),
+ eq(vendorResponseDetailView.rfqRecordId, Number(rfqRecordId))
+ )
+ )
+ .orderBy(asc(vendorResponseDetailView.attachmentId));
+
+ // 2. RFQ 진행 현황 요약 조회
+ const progressSummaryResult = await db
+ .select()
+ .from(rfqProgressSummaryView)
+ .where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0))
+ .limit(1);
+
+ const progressSummary = progressSummaryResult[0] || null;
+
+ // 3. 각 응답의 첨부파일 리비전 히스토리 조회
+ const attachmentHistories = await Promise.all(
+ responses.map(async (response) => {
+ const history = await db
+ .select()
+ .from(attachmentRevisionHistoryView)
+ .where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId))
+ .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
+
+ return {
+ attachmentId: response.attachmentId,
+ revisions: history
+ };
+ })
+ );
+
+ // 4. 벤더 응답 파일들 조회 (향상된 정보 포함)
+ const responseFiles = await Promise.all(
+ responses.map(async (response) => {
+ const files = await db
+ .select()
+ .from(vendorResponseAttachmentsEnhanced)
+ .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId))
+ .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
+
+ return {
+ responseId: response.responseId,
+ files: files
+ };
+ })
+ );
+
+ // 5. 데이터 변환 및 통합
+ const enhancedResponses = responses.map(response => {
+ const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId);
+ const responseFileData = responseFiles.find(f => f.responseId === response.responseId);
+
+ return {
+ ...response,
+ // 첨부파일 정보에 리비전 히스토리 추가
+ attachment: {
+ id: response.attachmentId,
+ attachmentType: response.attachmentType,
+ serialNo: response.serialNo,
+ description: response.attachmentDescription,
+ currentRevision: response.currentRevision,
+ // 모든 리비전 정보
+ revisions: attachmentHistory?.revisions?.map(rev => ({
+ id: rev.clientRevisionId,
+ revisionNo: rev.clientRevisionNo,
+ fileName: rev.clientFileName,
+ originalFileName: rev.clientFileName,
+ filePath: rev.clientFilePath, // 파일 경로 추가
+ fileSize: rev.clientFileSize,
+ revisionComment: rev.clientRevisionComment,
+ createdAt: rev.clientRevisionCreatedAt?.toISOString() || new Date().toISOString(),
+ isLatest: rev.isLatestClientRevision
+ })) || []
+ },
+ // 벤더 응답 파일들
+ responseAttachments: responseFileData?.files?.map(file => ({
+ id: file.responseAttachmentId,
+ fileName: file.fileName,
+ originalFileName: file.originalFileName,
+ filePath: file.filePath,
+ fileSize: file.fileSize,
+ description: file.description,
+ uploadedAt: file.uploadedAt?.toISOString() || new Date().toISOString(),
+ isLatestResponseFile: file.isLatestResponseFile,
+ fileSequence: file.fileSequence
+ })) || [],
+ // 리비전 분석 정보
+ isVersionMatched: response.isVersionMatched,
+ versionLag: response.versionLag,
+ needsUpdate: response.needsUpdate,
+ hasMultipleRevisions: response.hasMultipleRevisions,
+
+ // 새로 추가된 필드들
+ revisionRequestComment: response.revisionRequestComment,
+ revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null,
+ };
+ });
+
+ // RFQ 기본 정보 (첫 번째 응답에서 추출)
+ const rfqInfo = responses[0] ? {
+ id: responses[0].rfqId,
+ rfqCode: responses[0].rfqCode,
+ // 추가 정보는 기존 방식대로 별도 조회 필요
+ description: "",
+ dueDate: progressSummary?.dueDate || new Date(),
+ status: progressSummary?.rfqStatus || "DRAFT",
+ // ... 기타 필요한 정보들
+ } : null;
+
+ // 벤더 정보
+ const vendorInfo = responses[0] ? {
+ id: responses[0].vendorId,
+ vendorCode: responses[0].vendorCode,
+ vendorName: responses[0].vendorName,
+ country: responses[0].vendorCountry,
+ } : null;
+
+ // 통계 정보 계산
+ const calculateStats = (responses: typeof enhancedResponses) => {
+ const total = responses.length;
+ const responded = responses.filter(r => r.responseStatus === "RESPONDED").length;
+ const pending = responses.filter(r => r.responseStatus === "NOT_RESPONDED").length;
+ const revisionRequested = responses.filter(r => r.responseStatus === "REVISION_REQUESTED").length;
+ const waived = responses.filter(r => r.responseStatus === "WAIVED").length;
+ const versionMismatch = responses.filter(r => r.effectiveStatus === "VERSION_MISMATCH").length;
+ const upToDate = responses.filter(r => r.effectiveStatus === "UP_TO_DATE").length;
+
+ return {
+ total,
+ responded,
+ pending,
+ revisionRequested,
+ waived,
+ versionMismatch,
+ upToDate,
+ responseRate: total > 0 ? Math.round((responded / total) * 100) : 0,
+ completionRate: total > 0 ? Math.round(((responded + waived) / total) * 100) : 0,
+ versionMatchRate: responded > 0 ? Math.round((upToDate / responded) * 100) : 100
+ };
+ };
+
+ const statistics = calculateStats(enhancedResponses);
+
+ return {
+ data: enhancedResponses,
+ rfqInfo,
+ vendorInfo,
+ statistics,
+ progressSummary: progressSummary ? {
+ totalAttachments: progressSummary.totalAttachments,
+ attachmentsWithMultipleRevisions: progressSummary.attachmentsWithMultipleRevisions,
+ totalClientRevisions: progressSummary.totalClientRevisions,
+ totalResponseFiles: progressSummary.totalResponseFiles,
+ daysToDeadline: progressSummary.daysToDeadline
+ } : null
+ };
+
+ } catch (err) {
+ console.error("getRfqAttachmentResponsesWithRevisions 에러:", err);
+ return {
+ data: [],
+ rfqInfo: null,
+ vendorInfo: null,
+ statistics: {
+ total: 0,
+ responded: 0,
+ pending: 0,
+ revisionRequested: 0,
+ waived: 0,
+ versionMismatch: 0,
+ upToDate: 0,
+ responseRate: 0,
+ completionRate: 0,
+ versionMatchRate: 100
+ },
+ progressSummary: null
+ };
+ }
+}
+
+// 첨부파일 리비전 히스토리 조회
+export async function getAttachmentRevisionHistory(attachmentId: number) {
+
+ try {
+ const history = await db
+ .select()
+ .from(attachmentRevisionHistoryView)
+ .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
+ .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
+
+ return history;
+ } catch (err) {
+ console.error("getAttachmentRevisionHistory 에러:", err);
+ return [];
+ }
+ }
+
+// RFQ 전체 진행 현황 조회
+export async function getRfqProgressSummary(rfqId: number) {
+ try {
+ const summaryResult = await db
+ .select()
+ .from(rfqProgressSummaryView)
+ .where(eq(rfqProgressSummaryView.rfqId, rfqId))
+ .limit(1);
+
+ return summaryResult[0] || null;
+ } catch (err) {
+ console.error("getRfqProgressSummary 에러:", err);
+ return null;
+ }
+}
+
+// 벤더 응답 파일 상세 조회 (향상된 정보 포함)
+export async function getVendorResponseFiles(vendorResponseId: number) {
+ try {
+ const files = await db
+ .select()
+ .from(vendorResponseAttachmentsEnhanced)
+ .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
+ .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
+
+ return files;
+ } catch (err) {
+ console.error("getVendorResponseFiles 에러:", err);
+ return [];
+ }
+ }
+
+
+// 타입 정의 확장
+export type EnhancedVendorResponse = {
+ // 기본 응답 정보
+ responseId: number;
+ rfqId: number;
+ rfqCode: string;
+ rfqType: "INITIAL" | "FINAL";
+ rfqRecordId: number;
+
+ // 첨부파일 정보
+ attachmentId: number;
+ attachmentType: string;
+ serialNo: string;
+ attachmentDescription?: string;
+
+ // 벤더 정보
+ vendorId: number;
+ vendorCode: string;
+ vendorName: string;
+ vendorCountry: string;
+
+ // 응답 상태
+ responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
+ currentRevision: string;
+ respondedRevision?: string;
+ effectiveStatus: string;
+
+ // 코멘트 관련 필드들 (새로 추가된 필드 포함)
+ responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트
+ vendorComment?: string; // 벤더 내부 메모
+ revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가)
+
+ // 날짜 관련 필드들 (새로 추가된 필드 포함)
+ requestedAt: string;
+ respondedAt?: string;
+ revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가)
+
+ // 발주처 최신 리비전 정보
+ latestClientRevisionNo?: string;
+ latestClientFileName?: string;
+ latestClientFileSize?: number;
+ latestClientRevisionComment?: string;
+
+ // 리비전 분석
+ isVersionMatched: boolean;
+ versionLag?: number;
+ needsUpdate: boolean;
+ hasMultipleRevisions: boolean;
+
+ // 응답 파일 통계
+ totalResponseFiles: number;
+ latestResponseFileName?: string;
+ latestResponseFileSize?: number;
+ latestResponseUploadedAt?: string;
+
+ // 첨부파일 정보 (리비전 히스토리 포함)
+ attachment: {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ description?: string;
+ currentRevision: string;
+ revisions: Array<{
+ id: number;
+ revisionNo: string;
+ fileName: string;
+ originalFileName: string;
+ filePath?: string;
+ fileSize?: number;
+ revisionComment?: string;
+ createdAt: string;
+ isLatest: boolean;
+ }>;
+ };
+
+ // 벤더 응답 파일들
+ responseAttachments: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ filePath: string;
+ fileSize?: number;
+ description?: string;
+ uploadedAt: string;
+ isLatestResponseFile: boolean;
+ fileSequence: number;
+ }>;
+};
+
+
+export async function requestRevision(
+ responseId: number,
+ revisionReason: string
+): Promise<RequestRevisionResult> {
+ try {
+ // 입력값 검증
+ const validatedData = requestRevisionSchema.parse({
+ responseId,
+ revisionReason,
+ });
+
+ // 현재 응답 정보 조회
+ const existingResponse = await db
+ .select()
+ .from(vendorAttachmentResponses)
+ .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
+ .limit(1);
+
+ if (existingResponse.length === 0) {
+ return {
+ success: false,
+ message: "해당 응답을 찾을 수 없습니다",
+ error: "NOT_FOUND",
+ };
+ }
+
+ const response = existingResponse[0];
+
+ // 응답 상태 확인 (이미 응답되었거나 포기된 상태에서만 수정 요청 가능)
+ if (response.responseStatus !== "RESPONDED") {
+ return {
+ success: false,
+ message: "응답된 상태의 항목에서만 수정을 요청할 수 있습니다",
+ error: "INVALID_STATUS",
+ };
+ }
+
+ // 응답 상태를 REVISION_REQUESTED로 업데이트
+ const updateResult = await db
+ .update(vendorAttachmentResponses)
+ .set({
+ responseStatus: "REVISION_REQUESTED",
+ revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장
+ revisionRequestedAt: new Date(), // 수정 요청 시간 저장
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
+ .returning();
+
+ if (updateResult.length === 0) {
+ return {
+ success: false,
+ message: "수정 요청 업데이트에 실패했습니다",
+ error: "UPDATE_FAILED",
+ };
+ }
+
+ return {
+ success: true,
+ message: "수정 요청이 성공적으로 전송되었습니다",
+ };
+
+ } catch (error) {
+ console.error("Request revision server action error:", error);
+ return {
+ success: false,
+ message: "내부 서버 오류가 발생했습니다",
+ error: "INTERNAL_ERROR",
+ };
+ }
} \ No newline at end of file