diff options
Diffstat (limited to 'lib/bidding/service.ts')
| -rw-r--r-- | lib/bidding/service.ts | 815 |
1 files changed, 815 insertions, 0 deletions
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts new file mode 100644 index 00000000..91fea75e --- /dev/null +++ b/lib/bidding/service.ts @@ -0,0 +1,815 @@ +'use server' + +import db from '@/db/db' +import { + biddings, + biddingListView, + biddingNoticeTemplate, + projects, + biddingDocuments, + prItemsForBidding, + specificationMeetings +} from '@/db/schema' +import { + eq, + desc, + asc, + and, + or, + count, + sql, + ilike, + gte, + lte, + SQL +} from 'drizzle-orm' +import { revalidatePath } from 'next/cache' +import { BiddingListItem } from '@/db/schema' +import { filterColumns } from '@/lib/filter-columns' +import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation' +import { saveFile } from '../file-stroage' + + +export async function getBiddingNoticeTemplate() { + try { + const result = await db + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + return result[0] || null + } catch (error) { + console.error('Failed to get bidding notice template:', error) + throw new Error('입찰공고문 템플릿을 불러오는데 실패했습니다.') + } +} + +export async function saveBiddingNoticeTemplate(formData: { + title: string + content: string +}) { + try { + const { title, content } = formData + + // 기존 템플릿 확인 + const existing = await db + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + if (existing.length > 0) { + // 업데이트 + await db + .update(biddingNoticeTemplate) + .set({ + title, + content, + updatedAt: new Date(), + }) + .where(eq(biddingNoticeTemplate.type, 'standard')) + } else { + // 새로 생성 + await db.insert(biddingNoticeTemplate).values({ + type: 'standard', + title, + content, + }) + } + + revalidatePath('/admin/bidding-notice') + return { success: true, message: '입찰공고문 템플릿이 저장되었습니다.' } + } catch (error) { + console.error('Failed to save bidding notice template:', error) + throw new Error('입찰공고문 템플릿 저장에 실패했습니다.') + } +} + + +export async function getBiddings(input: GetBiddingsSchema) { + try { + const offset = (input.page - 1) * input.perPage + + // ✅ 1) 고급 필터 조건 + let advancedWhere: SQL<unknown> | undefined = undefined + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: biddingListView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }) + } + + // ✅ 2) 기본 필터 조건들 + const basicConditions: SQL<unknown>[] = [] + + if (input.biddingNumber) { + basicConditions.push(ilike(biddingListView.biddingNumber, `%${input.biddingNumber}%`)) + } + + if (input.status && input.status.length > 0) { + basicConditions.push( + or(...input.status.map(status => eq(biddingListView.status, status)))! + ) + } + + if (input.biddingType && input.biddingType.length > 0) { + basicConditions.push( + or(...input.biddingType.map(type => eq(biddingListView.biddingType, type)))! + ) + } + + if (input.contractType && input.contractType.length > 0) { + basicConditions.push( + or(...input.contractType.map(type => eq(biddingListView.contractType, type)))! + ) + } + + if (input.managerName) { + basicConditions.push(ilike(biddingListView.managerName, `%${input.managerName}%`)) + } + + // 날짜 필터들 + if (input.preQuoteDateFrom) { + basicConditions.push(gte(biddingListView.preQuoteDate, input.preQuoteDateFrom)) + } + if (input.preQuoteDateTo) { + basicConditions.push(lte(biddingListView.preQuoteDate, input.preQuoteDateTo)) + } + + if (input.submissionDateFrom) { + basicConditions.push(gte(biddingListView.submissionStartDate, input.submissionDateFrom)) + } + if (input.submissionDateTo) { + basicConditions.push(lte(biddingListView.submissionEndDate, input.submissionDateTo)) + } + + if (input.createdAtFrom) { + basicConditions.push(gte(biddingListView.createdAt, input.createdAtFrom)) + } + if (input.createdAtTo) { + basicConditions.push(lte(biddingListView.createdAt, input.createdAtTo)) + } + + // 가격 범위 필터 + if (input.budgetMin) { + basicConditions.push(gte(biddingListView.budget, input.budgetMin)) + } + if (input.budgetMax) { + basicConditions.push(lte(biddingListView.budget, input.budgetMax)) + } + + // Boolean 필터 + if (input.hasSpecificationMeeting === "true") { + basicConditions.push(eq(biddingListView.hasSpecificationMeeting, true)) + } else if (input.hasSpecificationMeeting === "false") { + basicConditions.push(eq(biddingListView.hasSpecificationMeeting, false)) + } + + if (input.hasPrDocument === "true") { + basicConditions.push(eq(biddingListView.hasPrDocument, true)) + } else if (input.hasPrDocument === "false") { + basicConditions.push(eq(biddingListView.hasPrDocument, false)) + } + + const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined + + // ✅ 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined + if (input.search) { + const s = `%${input.search}%` + const searchConditions = [ + ilike(biddingListView.biddingNumber, s), + ilike(biddingListView.title, s), + ilike(biddingListView.projectName, s), + ilike(biddingListView.itemName, s), + ilike(biddingListView.managerName, s), + ilike(biddingListView.prNumber, s), + ilike(biddingListView.remarks, s), + ] + globalWhere = or(...searchConditions) + } + + // ✅ 4) 최종 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 + + // ✅ 5) 전체 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(biddingListView) + .where(finalWhere) + + const total = totalResult[0]?.count || 0 + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 } + } + + console.log("Total biddings:", total) + + // ✅ 6) 정렬 및 페이징 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof biddingListView.$inferSelect + return sort.desc ? desc(biddingListView[column]) : asc(biddingListView[column]) + }) + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(biddingListView.createdAt)) + } + + // ✅ 7) 메인 쿼리 - 매우 간단해짐! + const data = await db + .select() + .from(biddingListView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset) + + const pageCount = Math.ceil(total / input.perPage) + + // ✅ 8) 포맷팅 불필요 - 뷰에서 이미 완성된 데이터! + return { data, pageCount, total } + + } catch (err) { + console.error("Error in getBiddings:", err) + return { data: [], pageCount: 0, total: 0 } + } +} +// 상태별 개수 집계 +export async function getBiddingStatusCounts() { + try { + const counts = await db + .select({ + status: biddings.status, + count: count(), + }) + .from(biddings) + .groupBy(biddings.status) + + return counts.reduce((acc, { status, count }) => { + acc[status] = count + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding status counts:', error) + return {} + } +} + +// 입찰유형별 개수 집계 +export async function getBiddingTypeCounts() { + try { + const counts = await db + .select({ + biddingType: biddings.biddingType, + count: count(), + }) + .from(biddings) + .groupBy(biddings.biddingType) + + return counts.reduce((acc, { biddingType, count }) => { + acc[biddingType] = count + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding type counts:', error) + return {} + } +} + +// 담당자별 개수 집계 +export async function getBiddingManagerCounts() { + try { + const counts = await db + .select({ + managerName: biddings.managerName, + count: count(), + }) + .from(biddings) + .where(sql`${biddings.managerName} IS NOT NULL AND ${biddings.managerName} != ''`) + .groupBy(biddings.managerName) + + return counts.reduce((acc, { managerName, count }) => { + if (managerName) { + acc[managerName] = count + } + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding manager counts:', error) + return {} + } +} + +// 월별 입찰 생성 통계 +export async function getBiddingMonthlyStats(year: number = new Date().getFullYear()) { + try { + const stats = await db + .select({ + month: sql<number>`EXTRACT(MONTH FROM ${biddings.createdAt})`.as('month'), + count: count(), + }) + .from(biddings) + .where(sql`EXTRACT(YEAR FROM ${biddings.createdAt}) = ${year}`) + .groupBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`) + .orderBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`) + + // 1-12월 전체 배열 생성 (없는 월은 0으로) + const monthlyData = Array.from({ length: 12 }, (_, i) => { + const month = i + 1 + const found = stats.find(stat => stat.month === month) + return { + month, + count: found?.count || 0, + } + }) + + return monthlyData + } catch (error) { + console.error('Failed to get bidding monthly stats:', error) + return [] + } +} + +export interface CreateBiddingInput extends CreateBiddingSchema { + // 사양설명회 정보 (선택사항) + specificationMeeting?: { + meetingDate: string + meetingTime: string + location: string + address: string + contactPerson: string + contactPhone: string + contactEmail: string + agenda: string + materials: string + notes: string + isRequired: boolean + } | null + + // PR 아이템들 (선택사항) + prItemsForBidding?: Array<{ + itemNumber: string + projectInfo: string + itemInfo: string + shi: string + requestedDeliveryDate: string + annualUnitPrice: string + currency: string + quantity: string + quantityUnit: string + totalWeight: string + weightUnit: string + materialDescription: string + prNumber: string + specFiles: File[] + }> + } + +export interface UpdateBiddingInput extends UpdateBiddingSchema { + id: number +} + +// 자동 입찰번호 생성 +async function generateBiddingNumber(biddingType: string): Promise<string> { + const year = new Date().getFullYear() + const typePrefix = { + 'equipment': 'EQ', + 'construction': 'CT', + 'service': 'SV', + 'lease': 'LS', + 'steel_stock': 'SS', + 'piping': 'PP', + 'transport': 'TP', + 'waste': 'WS', + '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 + } + + return `${year}${typePrefix}${sequence.toString().padStart(4, '0')}` +} + +// 입찰 생성 +export async function createBidding(input: CreateBiddingInput, userId: string) { + try { + return await db.transaction(async (tx) => { + // 자동 입찰번호 생성 + const biddingNumber = await generateBiddingNumber(input.biddingType) + + // 프로젝트 정보 조회 + let projectName = input.projectName + if (input.projectId && !projectName) { + const project = await tx + .select({ code: projects.code, name: projects.name }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1) + + if (project.length > 0) { + projectName = `${project[0].code} (${project[0].name})` + } + } + + // 표준 공고문 템플릿 가져오기 + let standardContent = '' + if (!input.content) { + try { + const template = await tx + .select({ content: biddingNoticeTemplate.content }) + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + if (template.length > 0) { + standardContent = template[0].content + } + } catch (error) { + console.warn('Failed to load standard template:', error) + } + } + + // 날짜 변환 함수 + const parseDate = (dateStr?: string) => { + if (!dateStr) return null + try { + return new Date(dateStr) + } catch { + return null + } + } + + // 1. 입찰 생성 + const [newBidding] = await tx + .insert(biddings) + .values({ + biddingNumber, + revision: input.revision || 0, + + // 프로젝트 정보 + projectId: input.projectId, + projectName, + + itemName: input.itemName, + title: input.title, + description: input.description, + content: input.content || standardContent, + + contractType: input.contractType, + biddingType: input.biddingType, + awardCount: input.awardCount, + contractPeriod: input.contractPeriod, + + // 자동 등록일 설정 + biddingRegistrationDate: new Date(), + submissionStartDate: parseDate(input.submissionStartDate), + submissionEndDate: parseDate(input.submissionEndDate), + evaluationDate: parseDate(input.evaluationDate), + + hasSpecificationMeeting: input.hasSpecificationMeeting || false, + hasPrDocument: input.hasPrDocument || false, + prNumber: input.prNumber, + + currency: input.currency, + budget: input.budget ? parseFloat(input.budget) : null, + targetPrice: input.targetPrice ? parseFloat(input.targetPrice) : null, + finalBidPrice: input.finalBidPrice ? parseFloat(input.finalBidPrice) : null, + + status: input.status || 'bidding_generated', + isPublic: input.isPublic || false, + managerName: input.managerName, + managerEmail: input.managerEmail, + managerPhone: input.managerPhone, + + remarks: input.remarks, + createdBy: userId, + updatedBy: userId, + }) + .returning({ id: biddings.id }) + + const biddingId = newBidding.id + + // 2. 사양설명회 정보 저장 (있는 경우) + if (input.specificationMeeting) { + const [newSpecMeeting] = await tx + .insert(specificationMeetings) + .values({ + biddingId, + meetingDate: new Date(input.specificationMeeting.meetingDate), + meetingTime: input.specificationMeeting.meetingTime, + location: input.specificationMeeting.location, + address: input.specificationMeeting.address, + contactPerson: input.specificationMeeting.contactPerson, + contactPhone: input.specificationMeeting.contactPhone, + contactEmail: input.specificationMeeting.contactEmail, + agenda: input.specificationMeeting.agenda, + materials: input.specificationMeeting.materials, + notes: input.specificationMeeting.notes, + isRequired: input.specificationMeeting.isRequired, + }) + .returning({ id: specificationMeetings.id }) + + // 2-1. 사양설명회 첨부파일 저장 + if (input.specificationMeeting.meetingFiles && input.specificationMeeting.meetingFiles.length > 0) { + for (const file of input.specificationMeeting.meetingFiles) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/specification-meeting`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + specificationMeetingId: newSpecMeeting.id, + documentType: 'specification_meeting', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.filePath!, + publicPath: saveResult.publicPath, + title: `사양설명회 - ${file.name}`, + isPublic: false, + isRequired: false, + uploadedBy: userId, + }) + } else { + console.error(`Failed to save specification meeting file: ${file.name}`, saveResult.error) + // 파일 저장 실패해도 전체 트랜잭션은 계속 진행 + } + } catch (error) { + console.error(`Error saving specification meeting file: ${file.name}`, error) + } + } + } + } + + // 3. PR 아이템들 저장 (있는 경우) + if (input.prItems && input.prItems.length > 0) { + for (const prItem of input.prItems) { + // PR 아이템 저장 + const [newPrItem] = await tx.insert(prItemsForBidding).values({ + biddingId, + itemNumber: prItem.itemCode, // itemCode를 itemNumber로 매핑 + projectInfo: '', // 필요시 추가 + itemInfo: prItem.itemInfo, + shi: '', // 필요시 추가 + requestedDeliveryDate: prItem.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : null, + annualUnitPrice: null, // 필요시 추가 + currency: 'KRW', // 기본값 또는 입력받은 값 + quantity: prItem.quantity ? parseFloat(prItem.quantity) : null, + quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게 + totalWeight: null, // 필요시 추가 + weightUnit: null, // 필요시 추가 + materialDescription: '', // 필요시 추가 + prNumber: prItem.prNumber, + hasSpecDocument: prItem.specFiles.length > 0, + isRepresentative: prItem.isRepresentative, + }).returning({ id: prItemsForBidding.id }) + + // 3-1. 스펙 파일들 저장 (있는 경우) + if (prItem.specFiles.length > 0) { + for (let fileIndex = 0; fileIndex < prItem.specFiles.length; fileIndex++) { + const file = prItem.specFiles[fileIndex] + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/pr-items/${newPrItem.id}/specs`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + prItemId: newPrItem.id, + documentType: 'spec', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.filePath!, + publicPath: saveResult.publicPath, + title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`, + description: `PR ${prItem.prNumber}의 스펙 문서`, + isPublic: false, + isRequired: false, + uploadedBy: userId, + displayOrder: fileIndex + 1, + }) + } else { + console.error(`Failed to save spec file: ${file.name}`, saveResult.error) + // 파일 저장 실패해도 전체 트랜잭션은 계속 진행 + } + } catch (error) { + console.error(`Error saving spec file: ${file.name}`, error) + } + } + } + } + } + + // 캐시 무효화 + revalidatePath('/evcp/bid') + + return { + success: true, + message: '입찰이 성공적으로 생성되었습니다.', + data: { id: biddingId, biddingNumber } + } + }) + } catch (error) { + console.error('Error creating bidding:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 생성 중 오류가 발생했습니다.' + } + } + } +// 입찰 수정 +export async function updateBidding(input: UpdateBiddingInput, userId: string) { + try { + // 존재 여부 확인 + const existing = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.id, input.id)) + .limit(1) + + if (existing.length === 0) { + return { + success: false, + error: '존재하지 않는 입찰입니다.' + } + } + + // 입찰번호 중복 체크 (다른 레코드에서) + if (input.biddingNumber) { + const duplicate = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.biddingNumber, input.biddingNumber)) + .limit(1) + + if (duplicate.length > 0 && duplicate[0].id !== input.id) { + return { + success: false, + error: '이미 존재하는 입찰번호입니다.' + } + } + } + + // 날짜 문자열을 Date 객체로 변환 + const parseDate = (dateStr?: string) => { + if (!dateStr) return undefined + try { + return new Date(dateStr) + } catch { + return undefined + } + } + + // 업데이트할 데이터 준비 + const updateData: any = { + updatedAt: new Date(), + updatedBy: userId, + } + + // 정의된 필드들만 업데이트 + if (input.biddingNumber !== undefined) updateData.biddingNumber = input.biddingNumber + if (input.revision !== undefined) updateData.revision = input.revision + if (input.projectName !== undefined) updateData.projectName = input.projectName + if (input.itemName !== undefined) updateData.itemName = input.itemName + if (input.title !== undefined) updateData.title = input.title + if (input.description !== undefined) updateData.description = input.description + if (input.content !== undefined) updateData.content = input.content + + if (input.contractType !== undefined) updateData.contractType = input.contractType + if (input.biddingType !== undefined) updateData.biddingType = input.biddingType + if (input.awardCount !== undefined) updateData.awardCount = input.awardCount + if (input.contractPeriod !== undefined) updateData.contractPeriod = input.contractPeriod + + if (input.preQuoteDate !== undefined) updateData.preQuoteDate = parseDate(input.preQuoteDate) + if (input.biddingRegistrationDate !== undefined) updateData.biddingRegistrationDate = parseDate(input.biddingRegistrationDate) + if (input.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(input.submissionStartDate) + if (input.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(input.submissionEndDate) + if (input.evaluationDate !== undefined) updateData.evaluationDate = parseDate(input.evaluationDate) + + if (input.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = input.hasSpecificationMeeting + if (input.hasPrDocument !== undefined) updateData.hasPrDocument = input.hasPrDocument + if (input.prNumber !== undefined) updateData.prNumber = input.prNumber + + if (input.currency !== undefined) updateData.currency = input.currency + if (input.budget !== undefined) updateData.budget = input.budget ? parseFloat(input.budget) : null + if (input.targetPrice !== undefined) updateData.targetPrice = input.targetPrice ? parseFloat(input.targetPrice) : null + if (input.finalBidPrice !== undefined) updateData.finalBidPrice = input.finalBidPrice ? parseFloat(input.finalBidPrice) : null + + if (input.status !== undefined) updateData.status = input.status + if (input.isPublic !== undefined) updateData.isPublic = input.isPublic + if (input.managerName !== undefined) updateData.managerName = input.managerName + if (input.managerEmail !== undefined) updateData.managerEmail = input.managerEmail + if (input.managerPhone !== undefined) updateData.managerPhone = input.managerPhone + + if (input.remarks !== undefined) updateData.remarks = input.remarks + + // 입찰 수정 + await db + .update(biddings) + .set(updateData) + .where(eq(biddings.id, input.id)) + + revalidatePath('/admin/biddings') + revalidatePath(`/admin/biddings/${input.id}`) + + return { + success: true, + message: '입찰이 성공적으로 수정되었습니다.' + } + + } catch (error) { + console.error('Error updating bidding:', error) + return { + success: false, + error: '입찰 수정 중 오류가 발생했습니다.' + } + } +} + +// 입찰 삭제 +export async function deleteBidding(id: number) { + try { + const existing = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.id, id)) + .limit(1) + + if (existing.length === 0) { + return { + success: false, + error: '존재하지 않는 입찰입니다.' + } + } + + await db + .delete(biddings) + .where(eq(biddings.id, id)) + + revalidatePath('/admin/biddings') + + return { + success: true, + message: '입찰이 성공적으로 삭제되었습니다.' + } + + } catch (error) { + console.error('Error deleting bidding:', error) + return { + success: false, + error: '입찰 삭제 중 오류가 발생했습니다.' + } + } +} + +// 단일 입찰 조회 +export async function getBiddingById(id: number) { + try { + const bidding = await db + .select() + .from(biddings) + .where(eq(biddings.id, id)) + .limit(1) + + if (bidding.length === 0) { + return null + } + + return bidding[0] + } catch (error) { + console.error('Error getting bidding:', error) + return null + } +} |
