diff options
Diffstat (limited to 'lib/b-rfq/service.ts')
| -rw-r--r-- | lib/b-rfq/service.ts | 975 |
1 files changed, 852 insertions, 123 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index f64eb46c..e60e446d 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -1,13 +1,27 @@ 'use server' import { revalidateTag, unstable_cache } from "next/cache" -import { count, desc, asc, and, or, gte, lte, ilike, eq } from "drizzle-orm" +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, bRfqs, projects, users } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +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 { CreateRfqInput, GetRFQDashboardSchema, createRfqServerSchema } from "./validations" +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( @@ -46,30 +60,30 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { let globalWhere: SQL<unknown> | undefined = undefined; if (input.search) { const s = `%${input.search}%`; - + const validSearchConditions: SQL<unknown>[] = []; - + 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); } @@ -79,11 +93,11 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { // 6) 최종 WHERE 조건 생성 const whereConditions: SQL<unknown>[] = []; - + if (advancedWhere) whereConditions.push(advancedWhere); if (basicWhere) whereConditions.push(basicWhere); if (globalWhere) whereConditions.push(globalWhere); - + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 7) 전체 데이터 수 조회 @@ -127,10 +141,8 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { } }, [JSON.stringify(input)], - { - revalidate: 3600, - tags: ["rfq-dashboard"], - } + { revalidate: 3600, tags: [tag.rfqDashboard] }, + )(); } @@ -165,127 +177,844 @@ function getRFQJoinedTables() { // ================================================================ async function generateNextSerial(picCode: string): Promise<string> { - 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" // 기본값 + 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<RfqDashboardView | null> => { + // 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<RfqDashboardView | null> => { + try { + + const rfq = await getBRfqById(id); + + return rfq; + } catch (error) { + throw new Error('Failed to fetch user'); } - 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, +}; + + +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<number, any> = {} + + 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<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' then 1 end)`, + pendingCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' then 1 end)`, + waivedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'WAIVED' then 1 end)`, }) - - // 관련 페이지 캐시 무효화 - revalidateTag("rfq-dashboard") + .from(vendorAttachmentResponses) + .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds)) + .groupBy(vendorAttachmentResponses.attachmentId) - - return { - success: true, - data: result[0], - message: "RFQ가 성공적으로 생성되었습니다", + // 응답률 계산해서 객체로 변환 + const statsMap: Record<number, any> = {} + 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 } - - } catch (error) { - console.error("RFQ 생성 오류:", error) - - - return { - success: false, - error: "RFQ 생성에 실패했습니다", + }) + + 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 : "문서 확정 중 오류가 발생했습니다.", } } - - // 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 +} + +// 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 요청 중 오류가 발생했습니다.", } } - - // 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` +} + +// 다음 시리얼 번호 생성 +async function getNextSerialNo(rfqId: number): Promise<string> { + 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 : "첨부파일 등록 중 오류가 발생했습니다.", } } +} -const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - // 1) RFQ 단건 조회 - const rfqsRes = await db - .select() - .from(rfqDashboardView) - .where(eq(rfqDashboardView.rfqId, id)) +// 리비전 추가 (기존 첨부파일에 새 버전 추가) +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 (rfqsRes.length === 0) return null; - const rfqRow = rfqsRes[0]; - - // 3) RfqWithItems 형태로 반환 - const result: RfqDashboardView = { - ...rfqRow, + 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, }; - - return result; - }; - + } 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 const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - try { - - const rfq = await getBRfqById(id); - return rfq; - } catch (error) { - throw new Error('Failed to fetch user'); +// 첨부파일 삭제 (리비전 포함) +export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") } - }; -
\ No newline at end of file + + 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 : "첨부파일 삭제 중 오류가 발생했습니다.", + } + } +}
\ No newline at end of file |
