// lib/vendor-rfq-response/vendor-tbe-service-simplified.ts 'use server' import { unstable_cache } from "next/cache" import db from "@/db/db" import { and, desc, asc, eq, sql, ne, or } from "drizzle-orm" import { tbeLastView, rfqLastTbeSessions } from "@/db/schema" import { rfqPrItems } from "@/db/schema/rfqLast" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { revalidateTag } from "next/cache" import { filterColumns } from "@/lib/filter-columns" // ========================================== // 간단한 벤더 Q&A 타입 정의 // ========================================== export interface VendorQuestion { id: string // UUID category: "general" | "technical" | "commercial" | "delivery" | "quality" | "document" | "clarification" question: string askedAt: string askedBy: number askedByName?: string answer?: string answeredAt?: string answeredBy?: number answeredByName?: string status: "open" | "answered" | "closed" priority?: "high" | "normal" | "low" attachments?: string[] // 파일 경로들 } // ========================================== // 1. 벤더용 TBE 세션 목록 조회 (기존 뷰 활용) // ========================================== export async function getTBEforVendor( input: any, vendorId: number ) { return unstable_cache( async () => { const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) const limit = input.perPage ?? 10 // 벤더 필터링 const vendorWhere = and(eq(tbeLastView.vendorId, vendorId), ne(tbeLastView.sessionStatus, "준비중")) // 고급 필터 추가 const advancedWhere = filterColumns({ table: tbeLastView, filters: input.filters ?? [], joinOperator: input.joinOperator ?? "and", }); // 글로벌 검색 추가 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( sql`${tbeLastView.sessionCode} ILIKE ${s}`, sql`${tbeLastView.rfqCode} ILIKE ${s}`, sql`${tbeLastView.rfqTitle} ILIKE ${s}`, sql`${tbeLastView.projectCode} ILIKE ${s}`, sql`${tbeLastView.projectName} ILIKE ${s}`, sql`${tbeLastView.packageNo} ILIKE ${s}`, sql`${tbeLastView.packageName} ILIKE ${s}` ); } // 최종 WHERE 조건 const whereConditions = [vendorWhere, advancedWhere]; if (globalWhere) { whereConditions.push(globalWhere); } const finalWhere = and(...whereConditions); // 정렬 처리 const orderBy = input.sort?.length ? input.sort.map((s: any) => { const col = (tbeLastView as any)[s.id]; return s.desc ? desc(col) : asc(col); }) : [desc(tbeLastView.createdAt)]; // 데이터 조회 const [rows, total] = await db.transaction(async (tx) => { const data = await tx .select() .from(tbeLastView) .where(finalWhere) .orderBy(...orderBy) .offset(offset) .limit(limit) const [{ count }] = await tx .select({ count: sql`count(*)`.as("count") }) .from(tbeLastView) .where(finalWhere) return [data, Number(count)] }) const pageCount = Math.ceil(total / limit) return { data: rows, pageCount } }, [`vendor-tbe-sessions-${vendorId}`, JSON.stringify(input)], { revalidate: 60, tags: [`vendor-tbe-sessions-${vendorId}`], } )() } // ========================================== // 2. 벤더 질문/코멘트 추가 (기존 필드 활용) // ========================================== export async function addVendorQuestion( sessionId: number, vendorId: number, question: Omit ) { const session = await getServerSession(authOptions) if (!session?.user) { throw new Error("인증이 필요합니다") } const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id // 권한 체크 const [tbeSession] = await db .select() .from(rfqLastTbeSessions) .where( and( eq(rfqLastTbeSessions.id, sessionId), eq(rfqLastTbeSessions.vendorId, vendorId) ) ) .limit(1) if (!tbeSession) { throw new Error("권한이 없습니다") } // 기존 질문 로그 가져오기 const existingQuestions = tbeSession.vendorQuestionsLog || [] // 새 질문 추가 const newQuestion: VendorQuestion = { id: crypto.randomUUID(), ...question, askedAt: new Date().toISOString(), askedBy: userId, status: "open" } // 업데이트 const [updated] = await db .update(rfqLastTbeSessions) .set({ vendorQuestionsLog: [...existingQuestions, newQuestion], vendorRemarks: tbeSession.vendorRemarks ? `${tbeSession.vendorRemarks}\n\n[${new Date().toLocaleString()}] ${question.question}` : `[${new Date().toLocaleString()}] ${question.question}`, updatedAt: new Date(), updatedBy: userId }) .where(eq(rfqLastTbeSessions.id, sessionId)) .returning() // 캐시 무효화 revalidateTag(`vendor-tbe-sessions-${vendorId}`) revalidateTag(`tbe-session-${sessionId}`) return newQuestion } // ========================================== // 3. 구매자가 답변 추가 // ========================================== export async function answerVendorQuestion( sessionId: number, questionId: string, answer: string ) { const session = await getServerSession(authOptions) if (!session?.user) { throw new Error("인증이 필요합니다") } const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id // TBE 세션 조회 const [tbeSession] = await db .select() .from(rfqLastTbeSessions) .where(eq(rfqLastTbeSessions.id, sessionId)) .limit(1) if (!tbeSession) { throw new Error("세션을 찾을 수 없습니다") } // 질문 로그 업데이트 const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] const updatedQuestions = questions.map(q => { if (q.id === questionId) { return { ...q, answer, answeredAt: new Date().toISOString(), answeredBy: userId, status: "answered" as const } } return q }) // 업데이트 const [updated] = await db .update(rfqLastTbeSessions) .set({ vendorQuestionsLog: updatedQuestions, updatedAt: new Date(), updatedBy: userId }) .where(eq(rfqLastTbeSessions.id, sessionId)) .returning() // 캐시 무효화 revalidateTag(`tbe-session-${sessionId}`) return updated } // ========================================== // 4. 벤더 질문 목록 조회 // ========================================== export async function getVendorQuestions( sessionId: number, vendorId: number ): Promise { // 권한 체크 const [tbeSession] = await db .select() .from(rfqLastTbeSessions) .where( and( eq(rfqLastTbeSessions.id, sessionId), eq(rfqLastTbeSessions.vendorId, vendorId) ) ) .limit(1) if (!tbeSession) { return [] } return (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] } // ========================================== // 5. 벤더 의견 업데이트 (간단한 텍스트) // ========================================== export async function updateVendorRemarks( sessionId: number, vendorId: number, remarks: string ) { const session = await getServerSession(authOptions) if (!session?.user) { throw new Error("인증이 필요합니다") } const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id // 권한 체크 const [tbeSession] = await db .select() .from(rfqLastTbeSessions) .where( and( eq(rfqLastTbeSessions.id, sessionId), eq(rfqLastTbeSessions.vendorId, vendorId) ) ) .limit(1) if (!tbeSession) { throw new Error("권한이 없습니다") } // 업데이트 const [updated] = await db .update(rfqLastTbeSessions) .set({ vendorRemarks: remarks, updatedAt: new Date(), updatedBy: userId }) .where(eq(rfqLastTbeSessions.id, sessionId)) .returning() // 캐시 무효화 revalidateTag(`vendor-tbe-sessions-${vendorId}`) revalidateTag(`tbe-session-${sessionId}`) return updated } // ========================================== // 6. 통계 조회 // ========================================== export async function getVendorQuestionStats(sessionId: number) { const [tbeSession] = await db .select() .from(rfqLastTbeSessions) .where(eq(rfqLastTbeSessions.id, sessionId)) .limit(1) if (!tbeSession) { return { total: 0, open: 0, answered: 0, closed: 0 } } const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] return { total: questions.length, open: questions.filter(q => q.status === "open").length, answered: questions.filter(q => q.status === "answered").length, closed: questions.filter(q => q.status === "closed").length, highPriority: questions.filter(q => q.priority === "high").length } } // ========================================== // 6. PR 아이템 조회 (벤더용) // ========================================== export async function getVendorPrItems( rfqId: number ) { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("로그인이 필요합니다."); } const vendorId = session.user.companyId // RFQ가 해당 벤더의 것인지 체크 const [tbeSession] = await db .select() .from(tbeLastView) .where( and( eq(tbeLastView.rfqId, rfqId), eq(tbeLastView.vendorId, vendorId) ) ) .limit(1) if (!tbeSession) { return [] } // PR 아이템 조회 const prItems = await db .select({ id: rfqPrItems.id, prNo: rfqPrItems.prNo, prItem: rfqPrItems.prItem, materialCode: rfqPrItems.materialCode, materialCategory: rfqPrItems.materialCategory, materialDescription: rfqPrItems.materialDescription, size: rfqPrItems.size, quantity: rfqPrItems.quantity, uom: rfqPrItems.uom, deliveryDate: rfqPrItems.deliveryDate, majorYn: rfqPrItems.majorYn, remarks: rfqPrItems.remark, }) .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)) .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem)) return prItems }