'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 { filterColumns } from "@/lib/filter-columns" import db from "@/db/db" import { RfqDashboardView, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 import { rfqDashboardView } from "@/db/schema" // 뷰 import import type { SQL } from "drizzle-orm" import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { unlink } from "fs/promises" const tag = { rfqDashboard: 'rfq-dashboard', rfq: (id: number) => `rfq-${id}`, rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`, attachmentRevisions: (attId: number) => `attachment-revisions-${attId}`, vendorResponses: ( attId: number, type: 'INITIAL' | 'FINAL' = 'INITIAL', ) => `vendor-responses-${attId}-${type}`, } as const; export async function getRFQDashboard(input: GetRFQDashboardSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; const rfqFilterMapping = createRFQFilterMapping(); const joinedTables = getRFQJoinedTables(); // 1) 고급 필터 조건 let advancedWhere: SQL | undefined = undefined; if (input.filters && input.filters.length > 0) { advancedWhere = filterColumns({ table: rfqDashboardView, filters: input.filters, joinOperator: input.joinOperator || 'and', joinedTables, customColumnMapping: rfqFilterMapping, }); } // 2) 기본 필터 조건 let basicWhere: SQL | undefined = undefined; if (input.basicFilters && input.basicFilters.length > 0) { basicWhere = filterColumns({ table: rfqDashboardView, filters: input.basicFilters, joinOperator: input.basicJoinOperator || 'and', joinedTables, customColumnMapping: rfqFilterMapping, }); } // 3) 글로벌 검색 조건 let globalWhere: SQL | undefined = undefined; if (input.search) { const s = `%${input.search}%`; const validSearchConditions: SQL[] = []; const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s); if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); const descriptionCondition = ilike(rfqDashboardView.description, s); if (descriptionCondition) validSearchConditions.push(descriptionCondition); const projectNameCondition = ilike(rfqDashboardView.projectName, s); if (projectNameCondition) validSearchConditions.push(projectNameCondition); const projectCodeCondition = ilike(rfqDashboardView.projectCode, s); if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); const picNameCondition = ilike(rfqDashboardView.picName, s); if (picNameCondition) validSearchConditions.push(picNameCondition); const packageNoCondition = ilike(rfqDashboardView.packageNo, s); if (packageNoCondition) validSearchConditions.push(packageNoCondition); const packageNameCondition = ilike(rfqDashboardView.packageName, s); if (packageNameCondition) validSearchConditions.push(packageNameCondition); if (validSearchConditions.length > 0) { globalWhere = or(...validSearchConditions); } } // 6) 최종 WHERE 조건 생성 const whereConditions: SQL[] = []; if (advancedWhere) whereConditions.push(advancedWhere); if (basicWhere) whereConditions.push(basicWhere); if (globalWhere) whereConditions.push(globalWhere); const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 7) 전체 데이터 수 조회 const totalResult = await db .select({ count: count() }) .from(rfqDashboardView) .where(finalWhere); const total = totalResult[0]?.count || 0; if (total === 0) { return { data: [], pageCount: 0, total: 0 }; } console.log(total) // 8) 정렬 및 페이징 처리된 데이터 조회 const orderByColumns = input.sort.map((sort) => { const column = sort.id as keyof typeof rfqDashboardView.$inferSelect; return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]); }); if (orderByColumns.length === 0) { orderByColumns.push(desc(rfqDashboardView.createdAt)); } const rfqData = await db .select() .from(rfqDashboardView) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) .offset(offset); const pageCount = Math.ceil(total / input.perPage); return { data: rfqData, pageCount, total }; } catch (err) { console.error("Error in getRFQDashboard:", err); return { data: [], pageCount: 0, total: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: [tag.rfqDashboard] }, )(); } // 헬퍼 함수들 function createRFQFilterMapping() { return { // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑 rfqCode: rfqDashboardView.rfqCode, description: rfqDashboardView.description, status: rfqDashboardView.status, projectName: rfqDashboardView.projectName, projectCode: rfqDashboardView.projectCode, picName: rfqDashboardView.picName, packageNo: rfqDashboardView.packageNo, packageName: rfqDashboardView.packageName, dueDate: rfqDashboardView.dueDate, overallProgress: rfqDashboardView.overallProgress, createdAt: rfqDashboardView.createdAt, }; } function getRFQJoinedTables() { return { // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음) projects, users, }; } // ================================================================ // 3. RFQ Dashboard 타입 정의 // ================================================================ async function generateNextSerial(picCode: string): Promise { try { // 해당 picCode로 시작하는 RFQ 개수 조회 const existingCount = await db .select({ count: count() }) .from(bRfqs) .where(eq(bRfqs.picCode, picCode)) const nextSerial = (existingCount[0]?.count || 0) + 1 return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 } catch (error) { console.error("시리얼 번호 생성 오류:", error) return "00001" // 기본값 } } export async function createRfqAction(input: CreateRfqInput) { try { // 입력 데이터 검증 const validatedData = createRfqServerSchema.parse(input) // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 const serialNumber = await generateNextSerial(validatedData.picCode) const rfqCode = `N${validatedData.picCode}${serialNumber}` // 데이터베이스에 삽입 const result = await db.insert(bRfqs).values({ rfqCode, projectId: validatedData.projectId, dueDate: validatedData.dueDate, status: "DRAFT", picCode: validatedData.picCode, picName: validatedData.picName || null, EngPicName: validatedData.engPicName || null, packageNo: validatedData.packageNo || null, packageName: validatedData.packageName || null, remark: validatedData.remark || null, projectCompany: validatedData.projectCompany || null, projectFlag: validatedData.projectFlag || null, projectSite: validatedData.projectSite || null, createdBy: validatedData.createdBy, updatedBy: validatedData.updatedBy, }).returning({ id: bRfqs.id, rfqCode: bRfqs.rfqCode, }) // 관련 페이지 캐시 무효화 revalidateTag(tag.rfqDashboard); revalidateTag(tag.rfq(id)); return { success: true, data: result[0], message: "RFQ가 성공적으로 생성되었습니다", } } catch (error) { console.error("RFQ 생성 오류:", error) return { success: false, error: "RFQ 생성에 실패했습니다", } } } // RFQ 코드 중복 확인 액션 export async function checkRfqCodeExists(rfqCode: string) { try { const existing = await db.select({ id: bRfqs.id }) .from(bRfqs) .where(eq(bRfqs.rfqCode, rfqCode)) .limit(1) return existing.length > 0 } catch (error) { console.error("RFQ 코드 확인 오류:", error) return false } } // picCode별 다음 예상 RFQ 코드 미리보기 export async function previewNextRfqCode(picCode: string) { try { const serialNumber = await generateNextSerial(picCode) return `N${picCode}${serialNumber}` } catch (error) { console.error("RFQ 코드 미리보기 오류:", error) return `N${picCode}00001` } } const getBRfqById = async (id: number): Promise => { // 1) RFQ 단건 조회 const rfqsRes = await db .select() .from(rfqDashboardView) .where(eq(rfqDashboardView.rfqId, id)) .limit(1); if (rfqsRes.length === 0) return null; const rfqRow = rfqsRes[0]; // 3) RfqWithItems 형태로 반환 const result: RfqDashboardView = { ...rfqRow, }; return result; }; export const findBRfqById = async (id: number): Promise => { try { const rfq = await getBRfqById(id); return rfq; } catch (error) { throw new Error('Failed to fetch user'); } }; export async function getRfqAttachments( input: GetRfqAttachmentsSchema, rfqId: number ) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage // Advanced Filter 처리 (메인 테이블 기준) const advancedWhere = filterColumns({ table: bRfqsAttachments, filters: input.filters, joinOperator: input.joinOperator, }) // 전역 검색 (첨부파일 + 리비전 파일명 검색) let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or( ilike(bRfqsAttachments.serialNo, s), ilike(bRfqsAttachments.description, s), ilike(bRfqsAttachments.currentRevision, s), ilike(bRfqAttachmentRevisions.fileName, s), ilike(bRfqAttachmentRevisions.originalFileName, s) ) } // 기본 필터 let basicWhere if (input.attachmentType.length > 0 || input.fileType.length > 0) { basicWhere = and( input.attachmentType.length > 0 ? inArray(bRfqsAttachments.attachmentType, input.attachmentType) : undefined, input.fileType.length > 0 ? inArray(bRfqAttachmentRevisions.fileType, input.fileType) : undefined ) } // 최종 WHERE 절 const finalWhere = and( eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건 advancedWhere, globalWhere, basicWhere ) // 정렬 (메인 테이블 기준) const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) ) : [desc(bRfqsAttachments.createdAt)] // 트랜잭션으로 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인) const data = await tx .select({ // 첨부파일 메인 정보 id: bRfqsAttachments.id, attachmentType: bRfqsAttachments.attachmentType, serialNo: bRfqsAttachments.serialNo, rfqId: bRfqsAttachments.rfqId, currentRevision: bRfqsAttachments.currentRevision, latestRevisionId: bRfqsAttachments.latestRevisionId, description: bRfqsAttachments.description, createdBy: bRfqsAttachments.createdBy, createdAt: bRfqsAttachments.createdAt, updatedAt: bRfqsAttachments.updatedAt, // 최신 리비전 파일 정보 fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, fileSize: bRfqAttachmentRevisions.fileSize, fileType: bRfqAttachmentRevisions.fileType, revisionComment: bRfqAttachmentRevisions.revisionComment, // 생성자 정보 createdByName: users.name, }) .from(bRfqsAttachments) .leftJoin( bRfqAttachmentRevisions, and( eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id), eq(bRfqAttachmentRevisions.isLatest, true) ) ) .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id)) .where(finalWhere) .orderBy(...orderBy) .limit(input.perPage) .offset(offset) // 전체 개수 조회 const totalResult = await tx .select({ count: count() }) .from(bRfqsAttachments) .leftJoin( bRfqAttachmentRevisions, eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id) ) .where(finalWhere) const total = totalResult[0]?.count ?? 0 return { data, total } }) const pageCount = Math.ceil(total / input.perPage) // 각 첨부파일별 벤더 응답 통계 조회 const attachmentIds = data.map(item => item.id) let responseStatsMap: Record = {} if (attachmentIds.length > 0) { responseStatsMap = await getAttachmentResponseStats(attachmentIds) } // 통계 데이터 병합 const dataWithStats = data.map(attachment => ({ ...attachment, responseStats: responseStatsMap[attachment.id] || { totalVendors: 0, respondedCount: 0, pendingCount: 0, waivedCount: 0, responseRate: 0 } })) return { data: dataWithStats, pageCount } } catch (err) { console.error("getRfqAttachments error:", err) return { data: [], pageCount: 0 } } }, [JSON.stringify(input), `${rfqId}`], { revalidate: 300, tags: [tag.rfqAttachments(rfqId)] }, )() } // 첨부파일별 벤더 응답 통계 조회 async function getAttachmentResponseStats(attachmentIds: number[]) { try { const stats = await db .select({ attachmentId: vendorAttachmentResponses.attachmentId, totalVendors: count(), respondedCount: sql`count(case when ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' then 1 end)`, pendingCount: sql`count(case when ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' then 1 end)`, waivedCount: sql`count(case when ${vendorAttachmentResponses.responseStatus} = 'WAIVED' then 1 end)`, }) .from(vendorAttachmentResponses) .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds)) .groupBy(vendorAttachmentResponses.attachmentId) // 응답률 계산해서 객체로 변환 const statsMap: Record = {} stats.forEach(stat => { const activeVendors = stat.totalVendors - stat.waivedCount const responseRate = activeVendors > 0 ? Math.round((stat.respondedCount / activeVendors) * 100) : 0 statsMap[stat.attachmentId] = { totalVendors: stat.totalVendors, respondedCount: stat.respondedCount, pendingCount: stat.pendingCount, waivedCount: stat.waivedCount, responseRate } }) return statsMap } catch (error) { console.error("getAttachmentResponseStats error:", error) return {} } } // 특정 첨부파일에 대한 벤더 응답 현황 상세 조회 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)] }, )() } export async function confirmDocuments(rfqId: number) { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } // TODO: RFQ 상태를 "Doc. Confirmed"로 업데이트 await db .update(bRfqs) .set({ status: "Doc. Confirmed", updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(bRfqs.id, rfqId)) revalidateTag(tag.rfq(rfqId)); revalidateTag(tag.rfqDashboard); revalidateTag(tag.rfqAttachments(rfqId)); return { success: true, message: "문서가 확정되었습니다.", } } catch (error) { console.error("confirmDocuments error:", error) return { success: false, message: error instanceof Error ? error.message : "문서 확정 중 오류가 발생했습니다.", } } } // TBE 요청 서버 액션 export async function requestTbe(rfqId: number, attachmentIds?: number[]) { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } // attachmentIds가 제공된 경우 해당 첨부파일들만 처리 let targetAttachments = [] if (attachmentIds && attachmentIds.length > 0) { // 선택된 첨부파일들 조회 targetAttachments = await db .select({ id: bRfqsAttachments.id, serialNo: bRfqsAttachments.serialNo, attachmentType: bRfqsAttachments.attachmentType, currentRevision: bRfqsAttachments.currentRevision, }) .from(bRfqsAttachments) .where( and( eq(bRfqsAttachments.rfqId, rfqId), inArray(bRfqsAttachments.id, attachmentIds) ) ) if (targetAttachments.length === 0) { throw new Error("선택된 첨부파일을 찾을 수 없습니다.") } } else { // 전체 RFQ의 모든 첨부파일 처리 targetAttachments = await db .select({ id: bRfqsAttachments.id, serialNo: bRfqsAttachments.serialNo, attachmentType: bRfqsAttachments.attachmentType, currentRevision: bRfqsAttachments.currentRevision, }) .from(bRfqsAttachments) .where(eq(bRfqsAttachments.rfqId, rfqId)) } if (targetAttachments.length === 0) { throw new Error("TBE 요청할 첨부파일이 없습니다.") } // TODO: TBE 요청 로직 구현 // 1. RFQ 상태를 "TBE started"로 업데이트 (선택적) // 2. 선택된 첨부파일들에 대해 벤더들에게 TBE 요청 이메일 발송 // 3. vendorAttachmentResponses 테이블에 TBE 요청 레코드 생성 // 4. TBE 관련 메타데이터 업데이트 // 예시: 선택된 첨부파일들에 대한 벤더 응답 레코드 생성 await db.transaction(async (tx) => { const [updatedRfq] = await tx .update(bRfqs) .set({ status: "TBE started", updatedBy: Number(session.user.id), updatedAt: new Date(), }) .where(eq(bRfqs.id, rfqId)) .returning() // 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트 for (const attachment of targetAttachments) { // TODO: 해당 첨부파일과 연관된 벤더들에게 TBE 요청 처리 console.log(`TBE 요청 처리: ${attachment.serialNo} (${attachment.currentRevision})`) } }) // 캐시 무효화 revalidateTag(`rfq-${rfqId}`) revalidateTag(`rfq-attachments-${rfqId}`) const attachmentCount = targetAttachments.length const attachmentList = targetAttachments .map(a => `${a.serialNo} (${a.currentRevision})`) .join(', ') return { success: true, message: `${attachmentCount}개 문서에 대한 TBE 요청이 전송되었습니다.\n대상: ${attachmentList}`, targetAttachments, } } catch (error) { console.error("requestTbe error:", error) return { success: false, message: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다.", } } } // 다음 시리얼 번호 생성 async function getNextSerialNo(rfqId: number): Promise { try { // 해당 RFQ의 기존 첨부파일 개수 조회 const [result] = await db .select({ count: count() }) .from(bRfqsAttachments) .where(eq(bRfqsAttachments.rfqId, rfqId)) const nextNumber = (result?.count || 0) + 1 // 001, 002, 003... 형태로 포맷팅 return nextNumber.toString().padStart(3, '0') } catch (error) { console.error("getNextSerialNo error:", error) // 에러 발생 시 타임스탬프 기반으로 fallback return Date.now().toString().slice(-3) } } export async function addRfqAttachmentRecord(record: AttachmentRecord) { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } const validatedRecord = attachmentRecordSchema.parse(record) const userId = Number(session.user.id) const result = await db.transaction(async (tx) => { // 1. 시리얼 번호 생성 const [countResult] = await tx .select({ count: count() }) .from(bRfqsAttachments) .where(eq(bRfqsAttachments.rfqId, validatedRecord.rfqId)) const serialNo = (countResult.count + 1).toString().padStart(3, '0') // 2. 메인 첨부파일 레코드 생성 const [attachment] = await tx .insert(bRfqsAttachments) .values({ rfqId: validatedRecord.rfqId, attachmentType: validatedRecord.attachmentType, serialNo: serialNo, currentRevision: "Rev.0", description: validatedRecord.description, createdBy: userId, }) .returning() // 3. 초기 리비전 (Rev.0) 생성 const [revision] = await tx .insert(bRfqAttachmentRevisions) .values({ attachmentId: attachment.id, revisionNo: "Rev.0", fileName: validatedRecord.fileName, originalFileName: validatedRecord.originalFileName, filePath: validatedRecord.filePath, fileSize: validatedRecord.fileSize, fileType: validatedRecord.fileType, revisionComment: validatedRecord.revisionComment || "초기 업로드", isLatest: true, createdBy: userId, }) .returning() // 4. 메인 테이블의 latest_revision_id 업데이트 await tx .update(bRfqsAttachments) .set({ latestRevisionId: revision.id, updatedAt: new Date(), }) .where(eq(bRfqsAttachments.id, attachment.id)) return { attachment, revision } }) revalidateTag(tag.rfq(validatedRecord.rfqId)); revalidateTag(tag.rfqDashboard); revalidateTag(tag.rfqAttachments(validatedRecord.rfqId)); return { success: true, message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`, attachment: result.attachment, revision: result.revision, } } catch (error) { console.error("addRfqAttachmentRecord error:", error) return { success: false, message: error instanceof Error ? error.message : "첨부파일 등록 중 오류가 발생했습니다.", } } } // 리비전 추가 (기존 첨부파일에 새 버전 추가) export async function addRevisionToAttachment( attachmentId: number, revisionData: { fileName: string; originalFileName: string; filePath: string; fileSize: number; fileType: string; revisionComment?: string; }, ) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) throw new Error('인증이 필요합니다.'); const userId = Number(session.user.id); // ──────────────────────────────────────────────────────────────────────────── // 0. 첨부파일의 rfqId 사전 조회 (태그 무효화를 위해 필요) // ──────────────────────────────────────────────────────────────────────────── const [attInfo] = await db .select({ rfqId: bRfqsAttachments.rfqId }) .from(bRfqsAttachments) .where(eq(bRfqsAttachments.id, attachmentId)) .limit(1); if (!attInfo) throw new Error('첨부파일을 찾을 수 없습니다.'); const rfqId = attInfo.rfqId; // ──────────────────────────────────────────────────────────────────────────── // 1‑5. 리비전 트랜잭션 // ──────────────────────────────────────────────────────────────────────────── const newRevision = await db.transaction(async (tx) => { // 1. 현재 최신 리비전 조회 const [latestRevision] = await tx .select({ revisionNo: bRfqAttachmentRevisions.revisionNo }) .from(bRfqAttachmentRevisions) .where( and( eq(bRfqAttachmentRevisions.attachmentId, attachmentId), eq(bRfqAttachmentRevisions.isLatest, true), ), ); if (!latestRevision) throw new Error('기존 첨부파일을 찾을 수 없습니다.'); // 2. 새 리비전 번호 생성 const currentNum = parseInt(latestRevision.revisionNo.replace('Rev.', '')); const newRevisionNo = `Rev.${currentNum + 1}`; // 3. 기존 리비전 isLatest → false await tx .update(bRfqAttachmentRevisions) .set({ isLatest: false }) .where( and( eq(bRfqAttachmentRevisions.attachmentId, attachmentId), eq(bRfqAttachmentRevisions.isLatest, true), ), ); // 4. 새 리비전 INSERT const [inserted] = await tx .insert(bRfqAttachmentRevisions) .values({ attachmentId, revisionNo: newRevisionNo, fileName: revisionData.fileName, originalFileName: revisionData.originalFileName, filePath: revisionData.filePath, fileSize: revisionData.fileSize, fileType: revisionData.fileType, revisionComment: revisionData.revisionComment ?? `${newRevisionNo} 업데이트`, isLatest: true, createdBy: userId, }) .returning(); // 5. 메인 첨부파일 row 업데이트 await tx .update(bRfqsAttachments) .set({ currentRevision: newRevisionNo, latestRevisionId: inserted.id, updatedAt: new Date(), }) .where(eq(bRfqsAttachments.id, attachmentId)); return inserted; }); // ──────────────────────────────────────────────────────────────────────────── // 6. 캐시 무효화 (rfqId 기준으로 수정) // ──────────────────────────────────────────────────────────────────────────── revalidateTag(tag.rfq(rfqId)); revalidateTag(tag.rfqDashboard); revalidateTag(tag.rfqAttachments(rfqId)); revalidateTag(tag.attachmentRevisions(attachmentId)); return { success: true, message: `새 리비전(${newRevision.revisionNo})이 성공적으로 추가되었습니다.`, revision: newRevision, }; } catch (error) { console.error('addRevisionToAttachment error:', error); return { success: false, message: error instanceof Error ? error.message : '리비전 추가 중 오류가 발생했습니다.', }; } } // 특정 첨부파일의 모든 리비전 조회 export async function getAttachmentRevisions(attachmentId: number) { return unstable_cache( async () => { try { const revisions = await db .select({ id: bRfqAttachmentRevisions.id, revisionNo: bRfqAttachmentRevisions.revisionNo, fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, fileSize: bRfqAttachmentRevisions.fileSize, fileType: bRfqAttachmentRevisions.fileType, revisionComment: bRfqAttachmentRevisions.revisionComment, isLatest: bRfqAttachmentRevisions.isLatest, createdBy: bRfqAttachmentRevisions.createdBy, createdAt: bRfqAttachmentRevisions.createdAt, createdByName: users.name, }) .from(bRfqAttachmentRevisions) .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id)) .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId)) .orderBy(desc(bRfqAttachmentRevisions.createdAt)) return { success: true, revisions, } } catch (error) { console.error("getAttachmentRevisions error:", error) return { success: false, message: "리비전 조회 중 오류가 발생했습니다.", revisions: [], } } }, [`${attachmentId}`], { revalidate: 180, tags: [tag.attachmentRevisions(attachmentId)] }, )() } // 첨부파일 삭제 (리비전 포함) export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } const validatedInput = deleteAttachmentsSchema.parse(input) const result = await db.transaction(async (tx) => { // 1. 삭제할 첨부파일들의 정보 조회 (파일 경로 포함) const attachmentsToDelete = await tx .select({ id: bRfqsAttachments.id, rfqId: bRfqsAttachments.rfqId, serialNo: bRfqsAttachments.serialNo, }) .from(bRfqsAttachments) .where(inArray(bRfqsAttachments.id, validatedInput.ids)) if (attachmentsToDelete.length === 0) { throw new Error("삭제할 첨부파일을 찾을 수 없습니다.") } // 2. 관련된 모든 리비전 파일 경로 조회 const revisionFilePaths = await tx .select({ filePath: bRfqAttachmentRevisions.filePath }) .from(bRfqAttachmentRevisions) .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) // 3. DB에서 리비전 삭제 (CASCADE로 자동 삭제되지만 명시적으로) await tx .delete(bRfqAttachmentRevisions) .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) // 4. DB에서 첨부파일 삭제 await tx .delete(bRfqsAttachments) .where(inArray(bRfqsAttachments.id, validatedInput.ids)) // 5. 실제 파일 삭제 (비동기로 처리) Promise.all( revisionFilePaths.map(async ({ filePath }) => { try { if (filePath) { const fullPath = `${process.cwd()}/public${filePath}` await unlink(fullPath) } } catch (fileError) { console.warn(`Failed to delete file: ${filePath}`, fileError) } }) ).catch(error => { console.error("Some files failed to delete:", error) }) return { deletedCount: attachmentsToDelete.length, rfqIds: [...new Set(attachmentsToDelete.map(a => a.rfqId))], attachments: attachmentsToDelete, } }) // 캐시 무효화 result.rfqIds.forEach(rfqId => { revalidateTag(`rfq-attachments-${rfqId}`) }) return { success: true, message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`, deletedAttachments: result.attachments, } } catch (error) { console.error("deleteRfqAttachments error:", error) return { success: false, message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.", } } }