summaryrefslogtreecommitdiff
path: root/lib/bidding/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/service.ts')
-rw-r--r--lib/bidding/service.ts2841
1 files changed, 2648 insertions, 193 deletions
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 5ab18ef1..80e4850f 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3,7 +3,6 @@
import db from '@/db/db'
import {
biddings,
- biddingListView,
biddingNoticeTemplate,
projects,
biddingDocuments,
@@ -14,7 +13,10 @@ import {
users,
basicContractTemplates,
vendorsWithTypesView,
- biddingCompanies
+ biddingCompanies,
+ biddingCompaniesContacts,
+ vendorContacts,
+ vendors
} from '@/db/schema'
import {
eq,
@@ -69,13 +71,40 @@ async function getUserNameById(userId: string): Promise<string> {
}
-export async function getBiddingNoticeTemplate() {
+export async function getBiddingNoticeTemplates() {
try {
const result = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
- .limit(1)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ // 타입별로 그룹화하여 반환
+ const templates = result.reduce((acc, template) => {
+ acc[template.type] = template
+ return acc
+ }, {} as Record<string, typeof result[0]>)
+
+ return templates
+ } catch (error) {
+ console.error('Failed to get bidding notice templates:', error)
+ throw new Error('입찰공고문 템플릿을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function getBiddingNoticeTemplate(type?: string) {
+ try {
+ let query = db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ if (type) {
+ query = query.where(eq(biddingNoticeTemplate.type, type))
+ }
+
+ const result = await query.limit(1)
return result[0] || null
} catch (error) {
@@ -84,18 +113,41 @@ export async function getBiddingNoticeTemplate() {
}
}
-export async function saveBiddingNoticeTemplate(formData: {
+export async function getBiddingNotice(biddingId: number) {
+ try {
+ const result = await db
+ .select({
+ id: biddingNoticeTemplate.id,
+ biddingId: biddingNoticeTemplate.biddingId,
+ title: biddingNoticeTemplate.title,
+ content: biddingNoticeTemplate.content,
+ isTemplate: biddingNoticeTemplate.isTemplate,
+ createdAt: biddingNoticeTemplate.createdAt,
+ updatedAt: biddingNoticeTemplate.updatedAt,
+ })
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ return result[0] || null
+ } catch (error) {
+ console.error('Failed to get bidding notice:', error)
+ throw new Error('입찰공고문을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNotice(biddingId: number, formData: {
title: string
content: string
}) {
try {
const { title, content } = formData
- // 기존 템플릿 확인
+ // 기존 입찰공고 확인
const existing = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
.limit(1)
if (existing.length > 0) {
@@ -107,13 +159,62 @@ export async function saveBiddingNoticeTemplate(formData: {
content,
updatedAt: new Date(),
})
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
} else {
// 새로 생성
await db.insert(biddingNoticeTemplate).values({
- type: 'standard',
+ biddingId,
title,
content,
+ isTemplate: false,
+ })
+ }
+
+ return { success: true, message: '입찰공고문이 저장되었습니다.' }
+ } catch (error) {
+ console.error('Failed to save bidding notice:', error)
+ throw new Error('입찰공고문 저장에 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNoticeTemplate(formData: {
+ title: string
+ content: string
+ type: string
+}) {
+ try {
+ const { title, content, type } = formData
+
+ // 기존 동일 타입의 템플릿 확인
+ const existing = await db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ .limit(1)
+
+ if (existing.length > 0) {
+ // 업데이트
+ await db
+ .update(biddingNoticeTemplate)
+ .set({
+ title,
+ content,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ } else {
+ // 새로 생성
+ await db.insert(biddingNoticeTemplate).values({
+ title,
+ content,
+ type,
+ isTemplate: true,
})
}
@@ -137,7 +238,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
let advancedWhere: SQL<unknown> | undefined = undefined
if (input.filters && input.filters.length > 0) {
advancedWhere = filterColumns({
- table: biddingListView,
+ table: biddings,
filters: input.filters,
joinOperator: input.joinOperator || 'and',
})
@@ -147,72 +248,83 @@ export async function getBiddings(input: GetBiddingsSchema) {
const basicConditions: SQL<unknown>[] = []
if (input.biddingNumber) {
- basicConditions.push(ilike(biddingListView.biddingNumber, `%${input.biddingNumber}%`))
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
}
if (input.status && input.status.length > 0) {
basicConditions.push(
- or(...input.status.map(status => eq(biddingListView.status, status)))!
+ or(...input.status.map(status => eq(biddings.status, status)))!
)
}
if (input.biddingType && input.biddingType.length > 0) {
basicConditions.push(
- or(...input.biddingType.map(type => eq(biddingListView.biddingType, type)))!
+ or(...input.biddingType.map(type => eq(biddings.biddingType, type)))!
)
}
if (input.contractType && input.contractType.length > 0) {
basicConditions.push(
- or(...input.contractType.map(type => eq(biddingListView.contractType, type)))!
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
)
}
+ if (input.purchasingOrganization) {
+ basicConditions.push(ilike(biddings.purchasingOrganization, `%${input.purchasingOrganization}%`))
+ }
+
+ // 담당자 필터 (bidPicId 또는 supplyPicId로 검색)
if (input.managerName) {
- basicConditions.push(ilike(biddingListView.managerName, `%${input.managerName}%`))
+ // managerName으로 검색 시 bidPic 또는 supplyPic의 이름으로 검색
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
}
// 날짜 필터들
if (input.preQuoteDateFrom) {
- basicConditions.push(gte(biddingListView.preQuoteDate, input.preQuoteDateFrom))
+ basicConditions.push(gte(biddings.preQuoteDate, input.preQuoteDateFrom))
}
if (input.preQuoteDateTo) {
- basicConditions.push(lte(biddingListView.preQuoteDate, input.preQuoteDateTo))
+ basicConditions.push(lte(biddings.preQuoteDate, input.preQuoteDateTo))
}
if (input.submissionDateFrom) {
- basicConditions.push(gte(biddingListView.submissionStartDate, new Date(input.submissionDateFrom)))
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
}
if (input.submissionDateTo) {
- basicConditions.push(lte(biddingListView.submissionEndDate, new Date(input.submissionDateTo)))
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
}
if (input.createdAtFrom) {
- basicConditions.push(gte(biddingListView.createdAt, new Date(input.createdAtFrom)))
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
}
if (input.createdAtTo) {
- basicConditions.push(lte(biddingListView.createdAt, new Date(input.createdAtTo)))
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
}
// 가격 범위 필터
if (input.budgetMin) {
- basicConditions.push(gte(biddingListView.budget, input.budgetMin))
+ basicConditions.push(gte(biddings.budget, input.budgetMin))
}
if (input.budgetMax) {
- basicConditions.push(lte(biddingListView.budget, input.budgetMax))
+ basicConditions.push(lte(biddings.budget, input.budgetMax))
}
// Boolean 필터
if (input.hasSpecificationMeeting === "true") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, true))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, true))
} else if (input.hasSpecificationMeeting === "false") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, false))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, false))
}
if (input.hasPrDocument === "true") {
- basicConditions.push(eq(biddingListView.hasPrDocument, true))
+ basicConditions.push(eq(biddings.hasPrDocument, true))
} else if (input.hasPrDocument === "false") {
- basicConditions.push(eq(biddingListView.hasPrDocument, false))
+ basicConditions.push(eq(biddings.hasPrDocument, false))
}
const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
@@ -222,13 +334,15 @@ export async function getBiddings(input: GetBiddingsSchema) {
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),
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.itemName, s),
+ ilike(biddings.purchasingOrganization, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.remarks, s),
]
globalWhere = or(...searchConditions)
}
@@ -244,7 +358,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 5) 전체 개수 조회
const totalResult = await db
.select({ count: count() })
- .from(biddingListView)
+ .from(biddings)
.where(finalWhere)
const total = totalResult[0]?.count || 0
@@ -257,18 +371,444 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 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])
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
})
if (orderByColumns.length === 0) {
- orderByColumns.push(desc(biddingListView.createdAt))
+ orderByColumns.push(desc(biddings.createdAt))
}
- // ✅ 7) 메인 쿼리 - 매우 간단해짐!
+ // ✅ 7) 메인 쿼리 - 이제 조인이 필요함!
const data = await db
- .select()
- .from(biddingListView)
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ biddingSourceType: biddings.biddingSourceType,
+ isUrgent: biddings.isUrgent,
+
+ // 계약 정보
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+
+ // 일정 관리
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+
+ // 회의 및 문서
+ hasSpecificationMeeting: biddings.hasSpecificationMeeting,
+ hasPrDocument: biddings.hasPrDocument,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ finalBidPrice: biddings.finalBidPrice,
+
+ // 상태 및 담당자
+ status: biddings.status,
+ isPublic: biddings.isPublic,
+ purchasingOrganization: biddings.purchasingOrganization,
+ bidPicId: biddings.bidPicId,
+ bidPicName: biddings.bidPicName,
+ supplyPicId: biddings.supplyPicId,
+ supplyPicName: biddings.supplyPicName,
+
+ // 메타 정보
+ remarks: biddings.remarks,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+
+ // 사양설명회 상세 정보
+ hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
+ meetingDate: specificationMeetings.meetingDate,
+ meetingLocation: specificationMeetings.location,
+ meetingContactPerson: specificationMeetings.contactPerson,
+ meetingIsRequired: specificationMeetings.isRequired,
+
+ // PR 문서 집계
+ prDocumentCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('pr_document_count'),
+
+ prDocumentNames: sql<string[]>`
+ (
+ SELECT array_agg(document_name ORDER BY registered_at DESC)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ LIMIT 5
+ )
+ `.as('pr_document_names'),
+
+ // 참여 현황 집계 (전체)
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ // === 사전견적 참여 현황 ===
+ preQuotePending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent')
+ ), 0)
+ `.as('pre_quote_pending'),
+
+ preQuoteAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_accepted'
+ ), 0)
+ `.as('pre_quote_accepted'),
+
+ preQuoteDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_declined'
+ ), 0)
+ `.as('pre_quote_declined'),
+
+ preQuoteSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('pre_quote_submitted'),
+
+ // === 입찰 참여 현황 ===
+ biddingPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_sent'
+ ), 0)
+ `.as('bidding_pending'),
+
+ biddingAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_accepted'
+ ), 0)
+ `.as('bidding_accepted'),
+
+ biddingDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_declined'
+ ), 0)
+ `.as('bidding_declined'),
+
+ biddingCancelled: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_cancelled'
+ ), 0)
+ `.as('bidding_cancelled'),
+
+ biddingSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('bidding_submitted'),
+
+ // === 호환성을 위한 기존 컬럼 (사전견적 기준) ===
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_declined', 'bidding_declined')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent', 'bidding_sent')
+ ), 0)
+ `.as('participant_pending'),
+
+ participantAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_accepted', 'bidding_accepted')
+ ), 0)
+ `.as('participant_accepted'),
+
+ // 참여율 계산 (입찰 기준 - 응찰 완료 / 전체)
+ participationRate: sql<number>`
+ CASE
+ WHEN (
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) > 0
+ THEN ROUND(
+ (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ) / (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) * 100, 1
+ )
+ ELSE 0
+ END
+ `.as('participation_rate'),
+
+ // 견적 금액 통계
+ avgPreQuoteAmount: sql<number>`
+ (
+ SELECT AVG(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('avg_pre_quote_amount'),
+
+ minPreQuoteAmount: sql<number>`
+ (
+ SELECT MIN(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('min_pre_quote_amount'),
+
+ maxPreQuoteAmount: sql<number>`
+ (
+ SELECT MAX(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('max_pre_quote_amount'),
+
+ avgFinalQuoteAmount: sql<number>`
+ (
+ SELECT AVG(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('avg_final_quote_amount'),
+
+ minFinalQuoteAmount: sql<number>`
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('min_final_quote_amount'),
+
+ maxFinalQuoteAmount: sql<number>`
+ (
+ SELECT MAX(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('max_final_quote_amount'),
+
+ // 선정 및 낙찰 정보
+ selectedForFinalBidCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_pre_quote_selected = true
+ ), 0)
+ `.as('selected_for_final_bid_count'),
+
+ winnerCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_winner = true
+ ), 0)
+ `.as('winner_count'),
+
+ winnerCompanyNames: sql<string[]>`
+ (
+ SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
+ FROM bidding_companies bc
+ JOIN vendors v ON bc.company_id = v.id
+ WHERE bc.bidding_id = ${biddings.id}
+ AND bc.is_winner = true
+ )
+ `.as('winner_company_names'),
+
+ // 일정 상태 계산
+ submissionStatus: sql<string>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
+ THEN 'not_scheduled'
+ WHEN NOW() < ${biddings.submissionStartDate}
+ THEN 'scheduled'
+ WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
+ THEN 'active'
+ WHEN NOW() > ${biddings.submissionEndDate}
+ THEN 'closed'
+ ELSE 'unknown'
+ END
+ `.as('submission_status'),
+
+ // 마감까지 남은 일수
+ daysUntilDeadline: sql<number>`
+ CASE
+ WHEN ${biddings.submissionEndDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionEndDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_deadline'),
+
+ // 시작까지 남은 일수
+ daysUntilStart: sql<number>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionStartDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_start'),
+
+ // 예산 대비 최저 견적 비율
+ budgetEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.budget} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('budget_efficiency_rate'),
+
+ // 내정가 대비 최저 견적 비율
+ targetPriceEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.targetPrice} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('target_price_efficiency_rate'),
+
+ // 입찰 진행 단계 점수 (0-100)
+ progressScore: sql<number>`
+ CASE ${biddings.status}
+ WHEN 'bidding_generated' THEN 10
+ WHEN 'request_for_quotation' THEN 20
+ WHEN 'received_quotation' THEN 40
+ WHEN 'set_target_price' THEN 60
+ WHEN 'bidding_opened' THEN 70
+ WHEN 'bidding_closed' THEN 80
+ WHEN 'evaluation_of_bidding' THEN 90
+ WHEN 'vendor_selected' THEN 100
+ WHEN 'bidding_disposal' THEN 0
+ ELSE 0
+ END
+ `.as('progress_score'),
+
+ // 마지막 활동일 (가장 최근 업체 응답일)
+ lastActivityDate: sql<Date>`
+ GREATEST(
+ ${biddings.updatedAt},
+ COALESCE((
+ SELECT MAX(updated_at)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), ${biddings.updatedAt})
+ )
+ `.as('last_activity_date'),
+ })
+ .from(biddings)
+ .leftJoin(
+ specificationMeetings,
+ sql`${biddings.id} = ${specificationMeetings.biddingId}`
+ )
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -305,80 +845,6 @@ export async function getBiddingStatusCounts() {
}
}
-// 입찰유형별 개수 집계
-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 {
// 사양설명회 정보 (선택사항)
@@ -401,17 +867,56 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
prItems?: Array<{
id: string
prNumber: string
- itemCode: string
- itemInfo: string
+ projectId?: number
+ projectInfo?: string
+ shi?: string
quantity: string
quantityUnit: string
totalWeight: string
weightUnit: string
+ materialGroupNumber: string
+ materialGroupInfo: string
+ materialNumber?: string
+ materialInfo?: string
materialDescription: string
hasSpecDocument: boolean
requestedDeliveryDate: string
specFiles: File[]
isRepresentative: boolean
+
+ // 가격 정보
+ annualUnitPrice?: string
+ currency?: string
+
+ // 단위 정보
+ priceUnit?: string
+ purchaseUnit?: string
+ materialWeight?: string
+
+ // WBS 정보
+ wbsCode?: string
+ wbsName?: string
+
+ // Cost Center 정보
+ costCenterCode?: string
+ costCenterName?: string
+
+ // GL Account 정보
+ glAccountCode?: string
+ glAccountName?: string
+
+ // 내정 정보
+ targetUnitPrice?: string
+ targetAmount?: string
+ targetCurrency?: string
+
+ // 예산 정보
+ budgetAmount?: string
+ budgetCurrency?: string
+
+ // 실적 정보
+ actualAmount?: string
+ actualCurrency?: string
}>
// 입찰 조건 (선택사항)
@@ -419,9 +924,10 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
paymentTerms: string
taxConditions: string
incoterms: string
- contractDeliveryDate: string
- shippingPort: string
- destinationPort: string
+ incotermsOption?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
isPriceAdjustmentApplicable: boolean
sparePartOptions: string
}
@@ -435,12 +941,54 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema {
id: number
}
+// 4자리 시퀀스 생성 (0001 -> 0009 -> 000A -> 000Z -> 0011 -> ...)
+function generateNextSequence(currentMax: string | null): string {
+ if (!currentMax) {
+ return '0001'; // 첫 번째 시퀀스
+ }
+
+ // 36진수로 변환 (0-9, A-Z)
+ const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+ // 4자리 시퀀스를 36진수로 해석
+ let value = 0;
+ for (let i = 0; i < 4; i++) {
+ const charIndex = chars.indexOf(currentMax[i]);
+ if (charIndex === -1) return '0001'; // 잘못된 문자면 초기화
+ value = value * 36 + charIndex;
+ }
+
+ // 1 증가
+ value += 1;
+
+ // 다시 4자리 36진수로 변환
+ let result = '';
+ for (let i = 0; i < 4; i++) {
+ const remainder = value % 36;
+ result = chars[remainder] + result;
+ value = Math.floor(value / 36);
+ }
+
+ // 4자리가 되도록 앞에 0 채우기
+ return result.padStart(4, '0');
+}
+
// 자동 입찰번호 생성
export async function generateBiddingNumber(
+ contractType: string,
userId?: string,
tx?: any,
maxRetries: number = 5
): Promise<string> {
+ // 계약 타입별 접두사 설정
+ const typePrefix = {
+ 'general': 'E',
+ 'unit_price': 'F',
+ 'sale': 'G'
+ };
+
+ const prefix = typePrefix[contractType as keyof typeof typePrefix] || 'E';
+
// user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
// userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
let purchaseManagerCode = '000';
@@ -458,27 +1006,25 @@ export async function generateBiddingNumber(
? purchaseManagerCode.substring(0, 3).toUpperCase()
: '000';
+ // 현재 년도 2자리
+ const currentYear = new Date().getFullYear().toString().slice(-2);
+
const dbInstance = tx || db;
- const prefix = `B${managerCode}`;
+ const yearPrefix = `${prefix}${managerCode}${currentYear}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
- // 현재 최대 일련번호 조회
+ // 현재 최대 시퀀스 조회 (년도별로, -01 제외하고 앞부분만)
+ const prefixLength = yearPrefix.length + 4;
const result = await dbInstance
- .select({
- maxNumber: sql<string>`MAX(${biddings.biddingNumber})`
+ .select({
+ maxNumber: sql<string>`MAX(LEFT(${biddings.biddingNumber}, ${prefixLength}))`
})
.from(biddings)
- .where(like(biddings.biddingNumber, `${prefix}%`));
+ .where(like(biddings.biddingNumber, `${yearPrefix}%`));
- let sequence = 1;
- if (result[0]?.maxNumber) {
- const lastSequence = parseInt(result[0].maxNumber.slice(-5));
- if (!isNaN(lastSequence)) {
- sequence = lastSequence + 1;
- }
- }
+ const nextSequence = generateNextSequence(result[0]?.maxNumber?.slice(-4) || null);
- const biddingNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
+ const biddingNumber = `${yearPrefix}${nextSequence}-01`;
// 중복 확인
const existing = await dbInstance
@@ -503,32 +1049,27 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const userName = await getUserNameById(userId)
return await db.transaction(async (tx) => {
// 자동 입찰번호 생성
- const biddingNumber = await generateBiddingNumber(userId)
+ const biddingNumber = await generateBiddingNumber(input.contractType, userId, tx)
- // 프로젝트 정보 조회
+ // 프로젝트 정보 조회 (PR 아이템에서 설정됨)
let projectName = input.projectName
- if (input.projectId) {
- 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})`
- }
- }
- // 표준 공고문 템플릿 가져오기
+ // 표준 공고문 템플릿 가져오기 (noticeType별)
let standardContent = ''
if (!input.content) {
try {
const template = await tx
.select({ content: biddingNoticeTemplate.content })
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(
+ and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, input.noticeType || 'standard')
+ )
+ )
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
.limit(1)
-
+
if (template.length > 0) {
standardContent = template[0].content
}
@@ -552,22 +1093,26 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
.insert(biddings)
.values({
biddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
revision: input.revision || 0,
- // 프로젝트 정보
- projectId: input.projectId,
+ // 프로젝트 정보 (PR 아이템에서 설정됨)
projectName,
itemName: input.itemName,
title: input.title,
description: input.description,
- content: input.content || standardContent,
contractType: input.contractType,
biddingType: input.biddingType,
awardCount: input.awardCount,
- contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : null,
- contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : null,
+ contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : new Date(),
+ contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : (() => {
+ const startDate = input.contractStartDate ? new Date(input.contractStartDate) : new Date()
+ const endDate = new Date(startDate)
+ endDate.setFullYear(endDate.getFullYear() + 1) // 1년 후
+ return endDate
+ })(),
// 자동 등록일 설정
biddingRegistrationDate: new Date(),
@@ -588,9 +1133,17 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// biddingSourceType: input.biddingSourceType || 'manual',
isPublic: input.isPublic || false,
isUrgent: input.isUrgent || false,
- managerName: input.managerName,
- managerEmail: input.managerEmail,
- managerPhone: input.managerPhone,
+
+ // 구매조직
+ purchasingOrganization: input.purchasingOrganization,
+
+ // 담당자 정보 (user FK)
+ bidPicId: input.bidPicId ? parseInt(input.bidPicId.toString()) : null,
+ bidPicName: input.bidPicName || null,
+ bidPicCode: input.bidPicCode || null,
+ supplyPicId: input.supplyPicId ? parseInt(input.supplyPicId.toString()) : null,
+ supplyPicName: input.supplyPicName || null,
+ supplyPicCode: input.supplyPicCode || null,
remarks: input.remarks,
createdBy: userName,
@@ -600,7 +1153,15 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const biddingId = newBidding.id
- // 2. 사양설명회 정보 저장 (있는 경우)
+ // 2. 입찰공고 생성 (템플릿에서 복제)
+ await tx.insert(biddingNoticeTemplate).values({
+ biddingId,
+ title: input.title + ' 입찰공고',
+ content: input.content || standardContent,
+ isTemplate: false,
+ })
+
+ // 3. 사양설명회 정보 저장 (있는 경우)
if (input.specificationMeeting) {
const [newSpecMeeting] = await tx
.insert(specificationMeetings)
@@ -666,9 +1227,10 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
paymentTerms: input.biddingConditions.paymentTerms,
taxConditions: input.biddingConditions.taxConditions,
incoterms: input.biddingConditions.incoterms,
+ incotermsOption: input.biddingConditions.incotermsOption,
contractDeliveryDate: input.biddingConditions.contractDeliveryDate || null,
- shippingPort: input.biddingConditions.shippingPort,
- destinationPort: input.biddingConditions.destinationPort,
+ shippingPort: input.biddingConditions.shippingPort || null,
+ destinationPort: input.biddingConditions.destinationPort || null,
isPriceAdjustmentApplicable: input.biddingConditions.isPriceAdjustmentApplicable,
sparePartOptions: input.biddingConditions.sparePartOptions,
})
@@ -684,18 +1246,61 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// PR 아이템 저장
const [newPrItem] = await tx.insert(prItemsForBidding).values({
biddingId,
- itemNumber: prItem.itemCode, // itemCode를 itemNumber로 매핑
- projectInfo: '', // 필요시 추가
- itemInfo: prItem.itemInfo,
- shi: '', // 필요시 추가
+ projectId: prItem.projectId, // 프로젝트 ID 추가
+ projectInfo: prItem.projectInfo || '', // 프로젝트 정보 (기존 호환성 유지)
+ shi: prItem.shi || '', // SHI 정보
requestedDeliveryDate: prItem.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : null,
- annualUnitPrice: null, // 필요시 추가
- currency: 'KRW', // 기본값 또는 입력받은 값
+
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: prItem.materialGroupNumber,
+ materialGroupInfo: prItem.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: prItem.materialNumber || null,
+ materialInfo: prItem.materialInfo || null,
+
+ // 가격 정보
+ annualUnitPrice: prItem.annualUnitPrice ? parseFloat(prItem.annualUnitPrice) : null,
+ currency: prItem.currency || 'KRW',
+
+ // 수량 및 중량
quantity: prItem.quantity ? parseFloat(prItem.quantity) : null,
- quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게
+ quantityUnit: prItem.quantityUnit as any,
totalWeight: prItem.totalWeight ? parseFloat(prItem.totalWeight) : null,
- weightUnit: prItem.weightUnit as any, // enum 타입에 맞게
- materialDescription: '', // 필요시 추가
+ weightUnit: prItem.weightUnit as any,
+
+ // 단위 정보
+ priceUnit: prItem.priceUnit || null,
+ purchaseUnit: prItem.purchaseUnit || null,
+ materialWeight: prItem.materialWeight ? parseFloat(prItem.materialWeight) : null,
+
+ // WBS 정보
+ wbsCode: prItem.wbsCode || null,
+ wbsName: prItem.wbsName || null,
+
+ // Cost Center 정보
+ costCenterCode: prItem.costCenterCode || null,
+ costCenterName: prItem.costCenterName || null,
+
+ // GL Account 정보
+ glAccountCode: prItem.glAccountCode || null,
+ glAccountName: prItem.glAccountName || null,
+
+ // 내정 정보
+ targetUnitPrice: prItem.targetUnitPrice ? parseFloat(prItem.targetUnitPrice) : null,
+ targetAmount: prItem.targetAmount ? parseFloat(prItem.targetAmount) : null,
+ targetCurrency: prItem.targetCurrency || 'KRW',
+
+ // 예산 정보
+ budgetAmount: prItem.budgetAmount ? parseFloat(prItem.budgetAmount) : null,
+ budgetCurrency: prItem.budgetCurrency || 'KRW',
+
+ // 실적 정보
+ actualAmount: prItem.actualAmount ? parseFloat(prItem.actualAmount) : null,
+ actualCurrency: prItem.actualCurrency || 'KRW',
+
+ // 상세 정보
+ materialDescription: prItem.materialDescription || '',
prNumber: prItem.prNumber,
hasSpecDocument: prItem.specFiles.length > 0,
isRepresentative: prItem.isRepresentative,
@@ -724,7 +1329,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
mimeType: file.type,
filePath: saveResult.publicPath!,
// publicPath: saveResult.publicPath,
- title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`,
+ title: `${prItem.materialGroupInfo || prItem.materialGroupNumber} 스펙 - ${file.name}`,
description: `PR ${prItem.prNumber}의 스펙 문서`,
isPublic: false,
isRequired: false,
@@ -840,9 +1445,15 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
if (input.status !== undefined) updateData.status = input.status
if (input.isPublic !== undefined) updateData.isPublic = input.isPublic
if (input.isUrgent !== undefined) updateData.isUrgent = input.isUrgent
- 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.purchasingOrganization !== undefined) updateData.purchasingOrganization = input.purchasingOrganization
+
+ // 담당자 정보 (user FK)
+ if (input.bidPicId !== undefined) updateData.bidPicId = input.bidPicId
+ if (input.bidPicName !== undefined) updateData.bidPicName = input.bidPicName
+ if (input.supplyPicId !== undefined) updateData.supplyPicId = input.supplyPicId
+ if (input.supplyPicName !== undefined) updateData.supplyPicName = input.supplyPicName
if (input.remarks !== undefined) updateData.remarks = input.remarks
@@ -908,6 +1519,12 @@ export async function deleteBidding(id: number) {
// 단일 입찰 조회
export async function getBiddingById(id: number) {
try {
+ // ID 유효성 검증
+ if (!id || isNaN(id) || id <= 0) {
+ console.warn('Invalid bidding ID provided to getBiddingById:', id)
+ return null
+ }
+
const bidding = await db
.select()
.from(biddings)
@@ -979,17 +1596,52 @@ export interface PRDetails {
}>
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
+ itemNumber: string | null
+ prNumber: string | null
+ projectInfo: string | null
+ shi: string | null
+ // 자재 그룹 정보
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ // 자재 정보
+ materialNumber: string | null
+ materialInfo: string | null
+ // 품목 정보
+ itemInfo: string | null
+ // 수량 및 중량
+ quantity: number | null
+ quantityUnit: string | null
+ totalWeight: number | null
+ weightUnit: string | null
+ // 가격 정보
+ annualUnitPrice: number | null
+ currency: string | null
+ // 단위 정보
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: number | null
+ // WBS 정보
+ wbsCode: string | null
+ wbsName: string | null
+ // Cost Center 정보
+ costCenterCode: string | null
+ costCenterName: string | null
+ // GL Account 정보
+ glAccountCode: string | null
+ glAccountName: string | null
+ // 내정 정보
+ targetUnitPrice: number | null
+ targetAmount: number | null
+ targetCurrency: string | null
+ // 예산 정보
+ budgetAmount: number | null
+ budgetCurrency: string | null
+ // 실적 정보
+ actualAmount: number | null
+ actualCurrency: string | null
+ // 납품 일정
+ requestedDeliveryDate: string | null
+ // SPEC 문서
hasSpecDocument: boolean
createdAt: string
updatedAt: string
@@ -1000,7 +1652,7 @@ export interface PRDetails {
fileSize: number
filePath: string
uploadedAt: string
- title?: string | null
+ title: string | null
}>
}>
}
@@ -1162,21 +1814,56 @@ export async function getPRDetailsAction(
)
)
- // 5. 데이터 직렬화
+ // 5. 데이터 직렬화 (모든 필드 포함)
return {
id: item.id,
itemNumber: item.itemNumber,
+ prNumber: item.prNumber,
+ projectInfo: item.projectInfo,
+ shi: item.shi,
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+ // 품목 정보
itemInfo: item.itemInfo,
+ // 수량 및 중량
quantity: item.quantity ? Number(item.quantity) : null,
quantityUnit: item.quantityUnit,
- requestedDeliveryDate: item.requestedDeliveryDate || 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,
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
+ currency: item.currency,
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight ? Number(item.materialWeight) : null,
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+ // 내정 정보
+ targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? Number(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency,
+ // 예산 정보
+ budgetAmount: item.budgetAmount ? Number(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency,
+ // 실적 정보
+ actualAmount: item.actualAmount ? Number(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency,
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate || null,
+ // 기타
+ hasSpecDocument: item.hasSpecDocument || false,
createdAt: item.createdAt?.toISOString() || '',
updatedAt: item.updatedAt?.toISOString() || '',
specDocuments: specDocuments.map(doc => ({
@@ -1302,12 +1989,688 @@ export async function getBiddingConditions(biddingId: number) {
}
// 입찰 조건 업데이트
+// === 입찰 관리 서버 액션들 ===
+
+// 입찰 기본 정보 업데이트 (관리 페이지용)
+export async function updateBiddingBasicInfo(
+ biddingId: number,
+ updates: {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+ },
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 존재 여부 확인
+ const existing = await db
+ .select({ id: biddings.id })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (existing.length === 0) {
+ return {
+ success: false,
+ error: '존재하지 않는 입찰입니다.'
+ }
+ }
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ // 숫자 문자열을 숫자로 변환 (빈 문자열은 null)
+ const parseNumeric = (value?: string): number | null | undefined => {
+ if (value === undefined) return undefined
+ if (value === '' || value === null) return null
+ const parsed = parseFloat(value)
+ return isNaN(parsed) ? null : parsed
+ }
+
+ // 업데이트할 데이터 준비
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ // 정의된 필드들만 업데이트
+ if (updates.title !== undefined) updateData.title = updates.title
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.content !== undefined) updateData.content = updates.content
+ if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
+ if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+ if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
+ if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
+ if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
+ if (updates.budget !== undefined) updateData.budget = parseNumeric(updates.budget)
+ if (updates.finalBidPrice !== undefined) updateData.finalBidPrice = parseNumeric(updates.finalBidPrice)
+ if (updates.targetPrice !== undefined) updateData.targetPrice = parseNumeric(updates.targetPrice)
+ if (updates.prNumber !== undefined) updateData.prNumber = updates.prNumber
+ if (updates.contractStartDate !== undefined) updateData.contractStartDate = parseDate(updates.contractStartDate)
+ if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
+ if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
+ if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
+ if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
+ if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
+ if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+ if (updates.purchasingOrganization !== undefined) updateData.purchasingOrganization = updates.purchasingOrganization
+ if (updates.bidPicName !== undefined) updateData.bidPicName = updates.bidPicName
+ if (updates.bidPicCode !== undefined) updateData.bidPicCode = updates.bidPicCode
+ if (updates.supplyPicName !== undefined) updateData.supplyPicName = updates.supplyPicName
+ if (updates.supplyPicCode !== undefined) updateData.supplyPicCode = updates.supplyPicCode
+ if (updates.requesterName !== undefined) updateData.requesterName = updates.requesterName
+ if (updates.remarks !== undefined) updateData.remarks = updates.remarks
+
+ // 데이터베이스 업데이트
+ await db
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+
+ return {
+ success: true,
+ message: '입찰 기본 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding basic info:', error)
+ return {
+ success: false,
+ error: '입찰 기본 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 일정 업데이트
+export async function updateBiddingSchedule(
+ biddingId: number,
+ schedule: {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+ },
+ userId: string,
+ specificationMeeting?: {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ }
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ return await db.transaction(async (tx) => {
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate)
+ if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate)
+ if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
+ if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
+ if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
+
+ await tx
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ // 사양설명회 정보 저장/업데이트
+ if (schedule.hasSpecificationMeeting && specificationMeeting) {
+ // 기존 사양설명회 정보 확인
+ const existingMeeting = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting.length > 0) {
+ // 기존 정보 업데이트
+ await tx
+ .update(specificationMeetings)
+ .set({
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ updatedAt: new Date(),
+ })
+ .where(eq(specificationMeetings.id, existingMeeting[0].id))
+ } else {
+ // 새로 생성
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId,
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ })
+ }
+ } else if (!schedule.hasSpecificationMeeting) {
+ // 사양설명회 실시 여부가 false로 변경된 경우, 관련 정보 삭제
+ await tx
+ .delete(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ }
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/schedule`)
+
+ return {
+ success: true,
+ message: '입찰 일정이 성공적으로 업데이트되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Failed to update bidding schedule:', error)
+ return {
+ success: false,
+ error: '입찰 일정 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 품목 관리 액션들
+export async function getBiddingItems(biddingId: number) {
+ try {
+ // PR 아이템 조회 (실제로는 prItemsForBidding 테이블에서 조회)
+ const items = await db
+ .select({
+ id: prItemsForBidding.id,
+ itemName: prItemsForBidding.itemName,
+ description: prItemsForBidding.description,
+ quantity: prItemsForBidding.quantity,
+ unit: prItemsForBidding.unit,
+ unitPrice: prItemsForBidding.unitPrice,
+ totalPrice: prItemsForBidding.totalPrice,
+ currency: prItemsForBidding.currency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+
+ return {
+ success: true,
+ data: items
+ }
+ } catch (error) {
+ console.error('Failed to get bidding items:', error)
+ return {
+ success: false,
+ error: '품목 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingItem(
+ itemId: number,
+ updates: {
+ itemName?: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date(),
+ }
+
+ if (updates.itemName !== undefined) updateData.itemName = updates.itemName
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.quantity !== undefined) updateData.quantity = updates.quantity
+ if (updates.unit !== undefined) updateData.unit = updates.unit
+ if (updates.unitPrice !== undefined) updateData.unitPrice = updates.unitPrice
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+
+ // 총액 자동 계산
+ if (updates.quantity !== undefined || updates.unitPrice !== undefined) {
+ const item = await db
+ .select({ quantity: prItemsForBidding.quantity, unitPrice: prItemsForBidding.unitPrice })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+ .limit(1)
+
+ if (item.length > 0) {
+ const quantity = updates.quantity ?? item[0].quantity ?? 0
+ const unitPrice = updates.unitPrice ?? item[0].unitPrice ?? 0
+ updateData.totalPrice = quantity * unitPrice
+ }
+ }
+
+ await db
+ .update(prItemsForBidding)
+ .set(updateData)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding item:', error)
+ return {
+ success: false,
+ error: '품목 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function addBiddingItem(
+ biddingId: number,
+ item: {
+ itemName: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const totalPrice = (item.quantity || 0) * (item.unitPrice || 0)
+
+ await db.insert(prItemsForBidding).values({
+ biddingId,
+ itemName: item.itemName,
+ description: item.description,
+ quantity: item.quantity || 0,
+ unit: item.unit,
+ unitPrice: item.unitPrice || 0,
+ totalPrice,
+ currency: item.currency || 'KRW',
+ })
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add bidding item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function removeBiddingItem(itemId: number) {
+ try {
+ await db
+ .delete(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to remove bidding item:', error)
+ return {
+ success: false,
+ error: '품목 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 추가 (전체 필드 지원)
+export async function addPRItemForBidding(
+ biddingId: number,
+ item: {
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ wbsCode?: string | null
+ wbsName?: string | null
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ actualAmount?: string | null
+ actualCurrency?: string | null
+ requestedDeliveryDate?: string | null
+ prNumber?: string | null
+ currency?: string | null
+ annualUnitPrice?: string | null
+ hasSpecDocument?: boolean
+ }
+) {
+ try {
+ const result = await db.insert(prItemsForBidding).values({
+ biddingId,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? parseFloat(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ prNumber: item.prNumber || null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ }).returning()
+
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ data: result[0],
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add PR item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 업체 관리 액션들
+export async function getBiddingVendors(biddingId: number) {
+ try {
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId, // 벤더 ID 추가
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ invitationStatus: biddingCompanies.invitationStatus,
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(biddingCompanies.id)
+
+ return {
+ success: true,
+ data: vendorsData
+ }
+ } catch (error) {
+ console.error('Failed to get bidding vendors:', error)
+ return {
+ success: false,
+ error: '업체 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingCompanyPriceAdjustmentQuestion(
+ biddingCompanyId: number,
+ isPriceAdjustmentApplicableQuestion: boolean
+) {
+ try {
+ await db
+ .update(biddingCompanies)
+ .set({
+ isPriceAdjustmentApplicableQuestion,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '연동제 적용요건 문의 여부가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update price adjustment question:', error)
+ return {
+ success: false,
+ error: '연동제 적용요건 문의 여부 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function updateVendorContact(
+ biddingCompanyId: number,
+ contact: {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ }
+) {
+ try {
+ // biddingCompanies 테이블에 연락처 정보가 직접 저장되어 있으므로 직접 업데이트
+ await db
+ .update(biddingCompanies)
+ .set({
+ contactPerson: contact.contactPerson,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '담당자 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update vendor contact:', error)
+ return {
+ success: false,
+ error: '담당자 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 참여 업체 담당자 관리 함수들
+export async function getBiddingCompanyContacts(biddingId: number, vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ biddingId: biddingCompaniesContacts.biddingId,
+ vendorId: biddingCompaniesContacts.vendorId,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ createdAt: biddingCompaniesContacts.createdAt,
+ updatedAt: biddingCompaniesContacts.updatedAt,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, vendorId)
+ )
+ )
+ .orderBy(asc(biddingCompaniesContacts.contactName))
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get bidding company contacts:', error)
+ return {
+ success: false,
+ error: '담당자 목록을 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function createBiddingCompanyContact(
+ biddingId: number,
+ vendorId: number,
+ contact: {
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }
+) {
+ try {
+ const [newContact] = await db
+ .insert(biddingCompaniesContacts)
+ .values({
+ biddingId,
+ vendorId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ })
+ .returning()
+
+ return {
+ success: true,
+ data: newContact,
+ message: '담당자가 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function deleteBiddingCompanyContact(contactId: number) {
+ try {
+ await db
+ .delete(biddingCompaniesContacts)
+ .where(eq(biddingCompaniesContacts.id, contactId))
+
+ return {
+ success: true,
+ message: '담당자가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 삭제에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
paymentTerms?: string
taxConditions?: string
incoterms?: string
+ incotermsOption?: string
contractDeliveryDate?: string
shippingPort?: string
destinationPort?: string
@@ -1328,6 +2691,7 @@ export async function updateBiddingConditions(
paymentTerms: updates.paymentTerms,
taxConditions: updates.taxConditions,
incoterms: updates.incoterms,
+ incotermsOption: updates.incotermsOption,
contractDeliveryDate: updates.contractDeliveryDate || null,
shippingPort: updates.shippingPort,
destinationPort: updates.destinationPort,
@@ -1367,6 +2731,272 @@ export async function updateBiddingConditions(
}
}
+// 사전견적용 일반견적 생성 액션
+export async function createPreQuoteRfqAction(input: {
+ biddingId: number
+ rfqType: string
+ rfqTitle: string
+ dueDate: Date
+ picUserId: number
+ projectId?: number
+ remark?: string
+ items: Array<{
+ itemCode: string
+ itemName: string
+ materialCode?: string
+ materialName?: string
+ quantity: number
+ uom: string
+ remark?: string
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ }
+ createdBy: number
+ updatedBy: number
+}) {
+ try {
+ // 일반견적 생성 서버 액션 및 필요한 스키마 import
+ const { createGeneralRfqAction } = await import('@/lib/rfq-last/service')
+ const { rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory } = await import('@/db/schema')
+
+ // 일반견적 생성
+ const result = await createGeneralRfqAction({
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ dueDate: input.dueDate,
+ picUserId: input.picUserId,
+ projectId: input.projectId,
+ remark: input.remark || '',
+ items: input.items.map(item => ({
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ quantity: item.quantity,
+ uom: item.uom,
+ remark: item.remark,
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ })),
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+
+ if (!result.success || !result.data) {
+ return {
+ success: false,
+ error: result.error || '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+
+ const rfqId = result.data.id
+ const conditions = input.biddingConditions
+
+ // 입찰 조건을 RFQ 조건으로 매핑
+ const mapBiddingConditionsToRfqConditions = () => {
+ if (!conditions) {
+ return {
+ currency: 'KRW',
+ paymentTermsCode: undefined,
+ incotermsCode: undefined,
+ incotermsDetail: undefined,
+ deliveryDate: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ materialPriceRelatedYn: false,
+ sparepartYn: false,
+ sparepartDescription: undefined,
+ }
+ }
+
+ // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
+ let deliveryDate: Date | undefined = undefined
+ if (conditions.contractDeliveryDate) {
+ try {
+ const date = new Date(conditions.contractDeliveryDate)
+ if (!isNaN(date.getTime())) {
+ deliveryDate = date
+ }
+ } catch (error) {
+ console.warn('Failed to parse contractDeliveryDate:', error)
+ }
+ }
+
+ return {
+ currency: 'KRW', // 기본값
+ paymentTermsCode: conditions.paymentTerms || undefined,
+ incotermsCode: conditions.incoterms || undefined,
+ incotermsDetail: conditions.incotermsOption || undefined,
+ deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
+ vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
+ taxCode: conditions.taxConditions || undefined,
+ placeOfShipping: conditions.shippingPort || undefined,
+ placeOfDestination: conditions.destinationPort || undefined,
+ materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
+ sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
+ sparepartDescription: conditions.sparePartOptions || undefined,
+ }
+ }
+
+ const rfqConditions = mapBiddingConditionsToRfqConditions()
+
+ // 입찰에 참여한 업체 목록 조회
+ const vendorsResult = await getBiddingVendors(input.biddingId)
+ if (!vendorsResult.success || !vendorsResult.data || vendorsResult.data.length === 0) {
+ return {
+ success: true,
+ message: '사전견적용 일반견적이 생성되었습니다. (참여 업체 없음)',
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ }
+
+ // 각 업체에 대해 rfqLastDetails와 rfqLastVendorResponses 생성
+ await db.transaction(async (tx) => {
+ for (const vendor of vendorsResult.data) {
+ if (!vendor.companyId) continue
+
+ // 1. rfqLastDetails 생성 (구매자 제시 조건)
+ const [rfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: rfqId,
+ vendorsId: vendor.companyId,
+ currency: rfqConditions.currency,
+ paymentTermsCode: rfqConditions.paymentTermsCode || null,
+ incotermsCode: rfqConditions.incotermsCode || null,
+ incotermsDetail: rfqConditions.incotermsDetail || null,
+ deliveryDate: rfqConditions.deliveryDate || null,
+ taxCode: rfqConditions.taxCode || null,
+ placeOfShipping: rfqConditions.placeOfShipping || null,
+ placeOfDestination: rfqConditions.placeOfDestination || null,
+ materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ sparepartYn: rfqConditions.sparepartYn,
+ sparepartDescription: rfqConditions.sparepartDescription || null,
+ updatedBy: input.updatedBy,
+ createdBy: input.createdBy,
+ isLatest: true,
+ })
+ .returning()
+
+ // 2. rfqLastVendorResponses 생성 (초기 응답 레코드)
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.companyId,
+ status: '대기중',
+ responseVersion: 1,
+ isLatest: true,
+ participationStatus: '미응답',
+ currency: rfqConditions.currency,
+ // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
+ vendorCurrency: rfqConditions.currency,
+ vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
+ vendorIncotermsCode: rfqConditions.incotermsCode || null,
+ vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
+ vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
+ vendorTaxCode: rfqConditions.taxCode || null,
+ vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
+ vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
+ vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ vendorSparepartYn: rfqConditions.sparepartYn,
+ vendorSparepartDescription: rfqConditions.sparepartDescription || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+ .returning()
+
+ // 3. 이력 기록
+ await tx
+ .insert(rfqLastVendorResponseHistory)
+ .values({
+ vendorResponseId: vendorResponse.id,
+ action: '생성',
+ newStatus: '대기중',
+ changeDetails: {
+ action: '사전견적용 일반견적 생성',
+ biddingId: input.biddingId,
+ conditions: rfqConditions,
+ },
+ performedBy: input.createdBy,
+ })
+ }
+ })
+
+ return {
+ success: true,
+ message: `사전견적용 일반견적이 성공적으로 생성되었습니다. (${vendorsResult.data.length}개 업체 추가)`,
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ } catch (error) {
+ console.error('Failed to create pre-quote RFQ:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+}
+
+// 일반견적 RFQ 코드 미리보기 (rfq-last/service에서 재사용)
+export async function previewGeneralRfqCode(picUserId: number): Promise<string> {
+ try {
+ const { previewGeneralRfqCode: previewCode } = await import('@/lib/rfq-last/service')
+ return await previewCode(picUserId)
+ } catch (error) {
+ console.error('Failed to preview general RFQ code:', error)
+ return 'F???00001'
+ }
+}
+
+// 내정가 산정 기준 업데이트
+export async function updateTargetPriceCalculationCriteria(
+ biddingId: number,
+ criteria: string,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ await db
+ .update(biddings)
+ .set({
+ targetPriceCalculationCriteria: criteria.trim() || null,
+ updatedAt: new Date(),
+ updatedBy: userName,
+ })
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/items`)
+
+ return {
+ success: true,
+ message: '내정가 산정 기준이 성공적으로 저장되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update target price calculation criteria:', error)
+ return {
+ success: false,
+ error: '내정가 산정 기준 저장에 실패했습니다.',
+ }
+ }
+}
+
// 활성 템플릿 조회 서버 액션
export async function getActiveContractTemplates() {
try {
@@ -1443,4 +3073,829 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
console.error('Error searching vendors for bidding:', error)
return []
}
+}
+
+// 차수증가 또는 재입찰 함수
+export async function increaseRoundOrRebid(biddingId: number, userId: string, type: 'round_increase' | 'rebidding') {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 기존 입찰 정보 조회
+ const [existingBidding] = await tx
+ .select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!existingBidding) {
+ return {
+ success: false,
+ error: '입찰 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 2. 입찰번호 파싱 및 차수 증가
+ const currentBiddingNumber = existingBidding.biddingNumber
+
+ // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02)
+ const match = currentBiddingNumber.match(/-(\d+)$/)
+ let currentRound = match ? parseInt(match[1]) : 1
+
+ let newBiddingNumber: string
+
+ if (currentRound >= 3) {
+ // -03 이상이면 새로운 번호 생성
+ newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ } else {
+ // -02까지는 차수만 증가
+ const baseNumber = currentBiddingNumber.split('-')[0]
+ newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ }
+
+ // 3. 새로운 입찰 생성 (기존 정보 복제)
+ const [newBidding] = await tx
+ .insert(biddings)
+ .values({
+ biddingNumber: newBiddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
+ revision: 0,
+ biddingSourceType: existingBidding.biddingSourceType,
+
+ // 기본 정보 복제
+ projectName: existingBidding.projectName,
+ itemName: existingBidding.itemName,
+ title: existingBidding.title,
+ description: existingBidding.description,
+
+ // 계약 정보 복제
+ contractType: existingBidding.contractType,
+ biddingType: existingBidding.biddingType,
+ awardCount: existingBidding.awardCount,
+ contractStartDate: existingBidding.contractStartDate,
+ contractEndDate: existingBidding.contractEndDate,
+
+ // 일정은 초기화 (새로 설정해야 함)
+ preQuoteDate: null,
+ biddingRegistrationDate: new Date(),
+ submissionStartDate: null,
+ submissionEndDate: null,
+ evaluationDate: null,
+
+ // 사양설명회
+ hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
+
+ // 예산 및 가격 정보 복제
+ currency: existingBidding.currency,
+ budget: existingBidding.budget,
+ targetPrice: existingBidding.targetPrice,
+ targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ finalBidPrice: null, // 최종입찰가는 초기화
+
+ // PR 정보 복제
+ prNumber: existingBidding.prNumber,
+ hasPrDocument: existingBidding.hasPrDocument,
+
+ // 상태는 내정가 산정으로 초기화
+ status: 'set_target_price',
+ isPublic: existingBidding.isPublic,
+ isUrgent: existingBidding.isUrgent,
+
+ // 구매조직
+ purchasingOrganization: existingBidding.purchasingOrganization,
+
+ // 담당자 정보 복제
+ bidPicId: existingBidding.bidPicId,
+ bidPicName: existingBidding.bidPicName,
+ bidPicCode: existingBidding.bidPicCode,
+ supplyPicId: existingBidding.supplyPicId,
+ supplyPicName: existingBidding.supplyPicName,
+ supplyPicCode: existingBidding.supplyPicCode,
+
+ remarks: `${type === 'round_increase' ? '차수증가' : '재입찰'}`,
+ createdBy: userName,
+ updatedBy: userName,
+ ANFNR: existingBidding.ANFNR,
+ })
+ .returning()
+
+ // 4. 입찰 조건 복제
+ const [existingConditions] = await tx
+ .select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ if (existingConditions) {
+ await tx
+ .insert(biddingConditions)
+ .values({
+ biddingId: newBidding.id,
+ paymentTerms: existingConditions.paymentTerms,
+ taxConditions: existingConditions.taxConditions,
+ incoterms: existingConditions.incoterms,
+ incotermsOption: existingConditions.incotermsOption,
+ contractDeliveryDate: existingConditions.contractDeliveryDate,
+ shippingPort: existingConditions.shippingPort,
+ destinationPort: existingConditions.destinationPort,
+ isPriceAdjustmentApplicable: existingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: existingConditions.sparePartOptions,
+ })
+ }
+
+ // 5. PR 아이템 복제
+ const existingPrItems = await tx
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ if (existingPrItems.length > 0) {
+ await tx
+ .insert(prItemsForBidding)
+ .values(
+ existingPrItems.map((item) => ({
+ biddingId: newBidding.id,
+
+ // 기본 정보
+ itemNumber: item.itemNumber,
+ projectId: item.projectId,
+ projectInfo: item.projectInfo,
+ itemInfo: item.itemInfo,
+ shi: item.shi,
+ prNumber: item.prNumber,
+
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate,
+
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice,
+ currency: item.currency,
+
+ // 수량 및 중량
+ quantity: item.quantity,
+ quantityUnit: item.quantityUnit,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight,
+
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+
+ // 내정가 정보
+ targetUnitPrice: item.targetUnitPrice,
+ targetAmount: item.targetAmount,
+ targetCurrency: item.targetCurrency,
+
+ // 예산 정보
+ budgetAmount: item.budgetAmount,
+ budgetCurrency: item.budgetCurrency,
+
+ // 실적 정보
+ actualAmount: item.actualAmount,
+ actualCurrency: item.actualCurrency,
+
+ // SPEC 문서 여부
+ hasSpecDocument: item.hasSpecDocument,
+
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ )
+ }
+
+ // 6. 벤더 복제 (제출 정보는 초기화)
+ const existingCompanies = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ if (existingCompanies.length > 0) {
+ await tx
+ .insert(biddingCompanies)
+ .values(
+ existingCompanies.map((company) => ({
+ biddingId: newBidding.id,
+ companyId: company.companyId,
+ invitedAt: new Date(),
+ invitationStatus: 'pending' as const, // 초대 대기 상태로 초기화
+ // 제출 정보는 초기화
+ submittedAt: null,
+ quotationPrice: null,
+ quotationCurrency: null,
+ quotationValidityDays: null,
+ deliveryDate: null,
+ remarks: null,
+ }))
+ )
+ }
+
+ // 7. 사양설명회 정보 복제 (있는 경우)
+ if (existingBidding.hasSpecificationMeeting) {
+ const [existingMeeting] = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting) {
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId: newBidding.id,
+ meetingDate: existingMeeting.meetingDate,
+ meetingTime: existingMeeting.meetingTime,
+ location: existingMeeting.location,
+ address: existingMeeting.address,
+ contactPerson: existingMeeting.contactPerson,
+ contactPhone: existingMeeting.contactPhone,
+ contactEmail: existingMeeting.contactEmail,
+ agenda: existingMeeting.agenda,
+ materials: existingMeeting.materials,
+ notes: existingMeeting.notes,
+ isRequired: existingMeeting.isRequired,
+ })
+ }
+ }
+ // 8. 입찰공고문 정보 복제 (있는 경우)
+ if (existingBidding.hasBiddingNotice) {
+ const [existingNotice] = await tx
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ if (existingNotice) {
+ await tx
+ .insert(biddingNoticeTemplate)
+ .values({
+ biddingId: newBidding.id,
+ title: existingNotice.title,
+ content: existingNotice.content,
+ })
+ }
+ }
+
+ revalidatePath('/bidding')
+ revalidatePath(`/bidding/${newBidding.id}`)
+
+ return {
+ success: true,
+ message: `${type === 'round_increase' ? '차수증가' : '재입찰'}가 완료되었습니다.`,
+ biddingId: newBidding.id,
+ biddingNumber: newBiddingNumber
+ }
+ })
+ } catch (error) {
+ console.error('차수증가/재입찰 실패:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '차수증가/재입찰 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+/**
+ * 벤더의 담당자 목록 조회
+ */
+export async function getVendorContactsByVendorId(vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ vendorId: vendorContacts.vendorId,
+ contactName: vendorContacts.contactName,
+ contactPosition: vendorContacts.contactPosition,
+ contactDepartment: vendorContacts.contactDepartment,
+ contactTask: vendorContacts.contactTask,
+ contactEmail: vendorContacts.contactEmail,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ createdAt: vendorContacts.createdAt,
+ updatedAt: vendorContacts.updatedAt,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName)
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get vendor contacts:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '담당자 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-receive 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-receive: 입찰서접수및마감 페이지용 입찰 목록 조회
+export async function getBiddingsForReceive(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (입찰서접수및마감에 적합한 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 입찰서 접수 및 마감과 관련된 상태만 필터링
+ // 'received_quotation', 'bidding_opened', 'bidding_closed' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'received_quotation'),
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-receive 페이지용 데이터 조회 (필요한 컬럼만 선택)
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+
+ // 참여 현황 집계
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('bidding_declined', 'bidding_cancelled')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted')
+ ), 0)
+ `.as('participant_pending'),
+
+ // 개찰 정보 (bidding_opened 상태에서만 의미 있음)
+ openedAt: biddings.updatedAt, // 개찰일은 업데이트 일시로 대체
+ openedBy: biddings.updatedBy, // 개찰자는 업데이트자로 대체
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForReceive:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-selection 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-selection: 개찰 이후 입찰가 및 정보 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForSelection(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (개찰 이후 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 개찰 이후 상태만 필터링
+ // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed'),
+ eq(biddings.status, 'evaluation_of_bidding'),
+ eq(biddings.status, 'vendor_selected')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-selection 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForSelection:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-failure 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-failure: 유찰된 입찰 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForFailure(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (유찰된 입찰만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 유찰된 상태만 필터링
+ basicConditions.push(eq(biddings.status, 'bidding_disposal'))
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.updatedAt)) // 유찰된 최신순
+ }
+
+ // bid-failure 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency,
+
+ // 일정 정보
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+
+ // 담당자 정보
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+
+ // 유찰 정보 (업데이트 일시를 유찰일로 사용)
+ disposalDate: biddings.updatedAt, // 유찰일
+ disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
+ disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+
+ // 기타 정보
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForFailure:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
} \ No newline at end of file