summaryrefslogtreecommitdiff
path: root/lib/b-rfq/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/b-rfq/service.ts')
-rw-r--r--lib/b-rfq/service.ts975
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