diff options
Diffstat (limited to 'lib/bidding/service.ts')
| -rw-r--r-- | lib/bidding/service.ts | 424 |
1 files changed, 401 insertions, 23 deletions
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 91fea75e..5d384476 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -8,7 +8,8 @@ import { projects, biddingDocuments, prItemsForBidding, - specificationMeetings + specificationMeetings, + prDocuments } from '@/db/schema' import { eq, @@ -21,7 +22,7 @@ import { ilike, gte, lte, - SQL + SQL, like } from 'drizzle-orm' import { revalidatePath } from 'next/cache' import { BiddingListItem } from '@/db/schema' @@ -91,6 +92,9 @@ export async function getBiddings(input: GetBiddingsSchema) { try { const offset = (input.page - 1) * input.perPage + console.log(input.filters) + console.log(input.sort) + // ✅ 1) 고급 필터 조건 let advancedWhere: SQL<unknown> | undefined = undefined if (input.filters && input.filters.length > 0) { @@ -378,7 +382,7 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema { } // 자동 입찰번호 생성 -async function generateBiddingNumber(biddingType: string): Promise<string> { +async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries: number = 5): Promise<string> { const year = new Date().getFullYear() const typePrefix = { 'equipment': 'EQ', @@ -392,22 +396,44 @@ async function generateBiddingNumber(biddingType: string): Promise<string> { 'sale': 'SL' }[biddingType] || 'GN' - // 해당 연도의 마지막 번호 조회 - const lastBidding = await db - .select({ biddingNumber: biddings.biddingNumber }) - .from(biddings) - .where(eq(biddings.biddingNumber, `${year}${typePrefix}%`)) - .orderBy(biddings.biddingNumber) - .limit(1) - - let sequence = 1 - if (lastBidding.length > 0) { - const lastNumber = lastBidding[0].biddingNumber - const lastSequence = parseInt(lastNumber.slice(-4)) - sequence = lastSequence + 1 + const dbInstance = tx || db + const prefix = `${year}${typePrefix}` + + for (let attempt = 0; attempt < maxRetries; attempt++) { + // 현재 최대 시퀀스 번호 조회 + const result = await dbInstance + .select({ + maxNumber: sql<string>`MAX(${biddings.biddingNumber})` + }) + .from(biddings) + .where(like(biddings.biddingNumber, `${prefix}%`)) + + let sequence = 1 + if (result[0]?.maxNumber) { + const lastSequence = parseInt(result[0].maxNumber.slice(-4)) + if (!isNaN(lastSequence)) { + sequence = lastSequence + 1 + } + } + + const biddingNumber = `${prefix}${sequence.toString().padStart(4, '0')}` + + // 중복 확인 + const existing = await dbInstance + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.biddingNumber, biddingNumber)) + .limit(1) + + if (existing.length === 0) { + return biddingNumber + } + + // 중복이 발견되면 잠시 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, 10 + Math.random() * 20)) } - return `${year}${typePrefix}${sequence.toString().padStart(4, '0')}` + throw new Error(`Failed to generate unique bidding number after ${maxRetries} attempts`) } // 입찰 생성 @@ -419,7 +445,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { // 프로젝트 정보 조회 let projectName = input.projectName - if (input.projectId && !projectName) { + if (input.projectId) { const project = await tx .select({ code: projects.code, name: projects.name }) .from(projects) @@ -549,8 +575,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { originalFileName: saveResult.originalName!, fileSize: saveResult.fileSize!, mimeType: file.type, - filePath: saveResult.filePath!, - publicPath: saveResult.publicPath, + filePath: saveResult.publicPath!, + // publicPath: saveResult.publicPath, title: `사양설명회 - ${file.name}`, isPublic: false, isRequired: false, @@ -606,13 +632,13 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { await tx.insert(biddingDocuments).values({ biddingId, prItemId: newPrItem.id, - documentType: 'spec', + documentType: 'spec_document', fileName: saveResult.fileName!, originalFileName: saveResult.originalName!, fileSize: saveResult.fileSize!, mimeType: file.type, - filePath: saveResult.filePath!, - publicPath: saveResult.publicPath, + filePath: saveResult.publicPath!, + // publicPath: saveResult.publicPath, title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`, description: `PR ${prItem.prNumber}의 스펙 문서`, isPublic: false, @@ -813,3 +839,355 @@ export async function getBiddingById(id: number) { return null } } + +// 공통 결과 타입 +interface ActionResult<T> { + success: boolean + data?: T + error?: string +} + +// 사양설명회 상세 정보 타입 +export interface SpecificationMeetingDetails { + id: number + biddingId: number + meetingDate: string + meetingTime?: string | null + location: string + address?: string | null + contactPerson: string + contactPhone?: string | null + contactEmail?: string | null + agenda?: string | null + materials?: string | null + notes?: string | null + isRequired: boolean + createdAt: string + updatedAt: string + documents: Array<{ + id: number + fileName: string + originalFileName: string + fileSize: number + filePath: string + title?: string | null + uploadedAt: string + uploadedBy?: string | null + }> +} + +// PR 상세 정보 타입 +export interface PRDetails { + documents: Array<{ + id: number + documentName: string + fileName: string + originalFileName: string + fileSize: number + filePath: string + registeredAt: string + registeredBy: string + version?: string | null + description?: string | null + createdAt: string + updatedAt: string + }> + items: Array<{ + id: number + itemNumber?: string | null + itemInfo: string + quantity?: number | null + quantityUnit?: string | null + requestedDeliveryDate?: string | null + prNumber?: string | null + annualUnitPrice?: number | null + currency: string + totalWeight?: number | null + weightUnit?: string | null + materialDescription?: string | null + hasSpecDocument: boolean + createdAt: string + updatedAt: string + specDocuments: Array<{ + id: number + fileName: string + originalFileName: string + fileSize: number + filePath: string + uploadedAt: string + title?: string | null + }> + }> +} + +/** + * 사양설명회 상세 정보 조회 서버 액션 + */ +export async function getSpecificationMeetingDetailsAction( + biddingId: number +): Promise<ActionResult<SpecificationMeetingDetails>> { + try { + // 1. 입력 검증 + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 2. 사양설명회 기본 정보 조회 + const meeting = await db + .select() + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, biddingId)) + .limit(1) + + if (meeting.length === 0) { + return { + success: false, + error: "사양설명회 정보를 찾을 수 없습니다" + } + } + + const meetingData = meeting[0] + + // 3. 관련 문서들 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy, + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'specification_meeting'), + eq(biddingDocuments.specificationMeetingId, meetingData.id) + ) + ) + + // 4. 데이터 직렬화 (Date 객체를 문자열로 변환) + const result: SpecificationMeetingDetails = { + id: meetingData.id, + biddingId: meetingData.biddingId, + meetingDate: meetingData.meetingDate?.toISOString() || '', + meetingTime: meetingData.meetingTime, + location: meetingData.location, + address: meetingData.address, + contactPerson: meetingData.contactPerson, + contactPhone: meetingData.contactPhone, + contactEmail: meetingData.contactEmail, + agenda: meetingData.agenda, + materials: meetingData.materials, + notes: meetingData.notes, + isRequired: meetingData.isRequired, + createdAt: meetingData.createdAt?.toISOString() || '', + updatedAt: meetingData.updatedAt?.toISOString() || '', + documents: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + title: doc.title, + uploadedAt: doc.uploadedAt?.toISOString() || '', + uploadedBy: doc.uploadedBy, + })) + } + + return { + success: true, + data: result + } + + } catch (error) { + console.error("사양설명회 상세 정보 조회 실패:", error) + return { + success: false, + error: "사양설명회 정보 조회 중 오류가 발생했습니다" + } + } +} + +/** + * PR 상세 정보 조회 서버 액션 + */ +export async function getPRDetailsAction( + biddingId: number +): Promise<ActionResult<PRDetails>> { + try { + // 1. 입력 검증 + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 2. PR 문서들 조회 + const documents = await db + .select({ + id: prDocuments.id, + documentName: prDocuments.documentName, + fileName: prDocuments.fileName, + originalFileName: prDocuments.originalFileName, + fileSize: prDocuments.fileSize, + filePath: prDocuments.filePath, + registeredAt: prDocuments.registeredAt, + registeredBy: prDocuments.registeredBy, + version: prDocuments.version, + description: prDocuments.description, + createdAt: prDocuments.createdAt, + updatedAt: prDocuments.updatedAt, + }) + .from(prDocuments) + .where(eq(prDocuments.biddingId, biddingId)) + + // 3. PR 아이템들 조회 + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + // 4. 각 아이템별 스펙 문서들 조회 + const itemsWithDocs = await Promise.all( + items.map(async (item) => { + const specDocuments = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt, + title: biddingDocuments.title, + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'spec_document'), + eq(biddingDocuments.prItemId, item.id) + ) + ) + + // 5. 데이터 직렬화 + return { + id: item.id, + itemNumber: item.itemNumber, + itemInfo: item.itemInfo, + quantity: item.quantity ? Number(item.quantity) : null, + quantityUnit: item.quantityUnit, + requestedDeliveryDate: item.requestedDeliveryDate?.toISOString().split('T')[0] || null, + prNumber: item.prNumber, + annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null, + currency: item.currency, + totalWeight: item.totalWeight ? Number(item.totalWeight) : null, + weightUnit: item.weightUnit, + materialDescription: item.materialDescription, + hasSpecDocument: item.hasSpecDocument, + createdAt: item.createdAt?.toISOString() || '', + updatedAt: item.updatedAt?.toISOString() || '', + specDocuments: specDocuments.map(doc => ({ + id: doc.id, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + uploadedAt: doc.uploadedAt?.toISOString() || '', + title: doc.title, + })) + } + }) + ) + + const result: PRDetails = { + documents: documents.map(doc => ({ + id: doc.id, + documentName: doc.documentName, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + registeredAt: doc.registeredAt?.toISOString() || '', + registeredBy: doc.registeredBy, + version: doc.version, + description: doc.description, + createdAt: doc.createdAt?.toISOString() || '', + updatedAt: doc.updatedAt?.toISOString() || '', + })), + items: itemsWithDocs + } + + return { + success: true, + data: result + } + + } catch (error) { + console.error("PR 상세 정보 조회 실패:", error) + return { + success: false, + error: "PR 정보 조회 중 오류가 발생했습니다" + } + } +} + + + +/** + * 입찰 기본 정보 조회 서버 액션 (선택사항) + */ +export async function getBiddingBasicInfoAction( + biddingId: number +): Promise<ActionResult<{ + id: number + title: string + hasSpecificationMeeting: boolean + hasPrDocument: boolean +}>> { + try { + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 간단한 입찰 정보만 조회 (성능 최적화) + const bidding = await db.query.biddings.findFirst({ + where: (biddings, { eq }) => eq(biddings.id, biddingId), + columns: { + id: true, + title: true, + hasSpecificationMeeting: true, + hasPrDocument: true, + } + }) + + if (!bidding) { + return { + success: false, + error: "입찰 정보를 찾을 수 없습니다" + } + } + + return { + success: true, + data: bidding + } + + } catch (error) { + console.error("입찰 기본 정보 조회 실패:", error) + return { + success: false, + error: "입찰 기본 정보 조회 중 오류가 발생했습니다" + } + } +}
\ No newline at end of file |
