/** * BidService - Gestión de Licitaciones * * CRUD y lógica de negocio para licitaciones/propuestas. * * @module Bidding */ import { Repository, In, Between } from 'typeorm'; import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity'; export interface CreateBidDto { opportunityId: string; code: string; name: string; description?: string; bidType: BidType; tenderNumber?: string; tenderName?: string; contractingEntity?: string; publicationDate?: Date; siteVisitDate?: Date; clarificationDeadline?: Date; submissionDeadline: Date; openingDate?: Date; baseBudget?: number; currency?: string; technicalWeight?: number; economicWeight?: number; bidBondAmount?: number; bidManagerId?: string; notes?: string; metadata?: Record; } export interface UpdateBidDto extends Partial { status?: BidStatus; stage?: BidStage; ourProposalAmount?: number; technicalScore?: number; economicScore?: number; finalScore?: number; rankingPosition?: number; bidBondNumber?: string; bidBondExpiry?: Date; awardDate?: Date; contractSigningDate?: Date; winnerName?: string; winningAmount?: number; rejectionReason?: string; lessonsLearned?: string; completionPercentage?: number; checklist?: Record; } export interface BidFilters { status?: BidStatus | BidStatus[]; bidType?: BidType; stage?: BidStage; opportunityId?: string; bidManagerId?: string; contractingEntity?: string; deadlineFrom?: Date; deadlineTo?: Date; minBudget?: number; maxBudget?: number; search?: string; } export class BidService { constructor(private readonly repository: Repository) {} /** * Crear licitación */ async create(ctx: ServiceContext, data: CreateBidDto): Promise { const bid = this.repository.create({ tenantId: ctx.tenantId, ...data, status: 'draft', stage: 'initial', completionPercentage: 0, createdBy: ctx.userId, updatedBy: ctx.userId, }); return this.repository.save(bid); } /** * Buscar por ID */ async findById(ctx: ServiceContext, id: string): Promise { return this.repository.findOne({ where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'], }); } /** * Buscar con filtros */ async findWithFilters( ctx: ServiceContext, filters: BidFilters, page = 1, limit = 20 ): Promise> { const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.opportunity', 'o') .leftJoinAndSelect('b.bidManager', 'm') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.deleted_at IS NULL'); if (filters.status) { if (Array.isArray(filters.status)) { qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status }); } else { qb.andWhere('b.status = :status', { status: filters.status }); } } if (filters.bidType) { qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType }); } if (filters.stage) { qb.andWhere('b.stage = :stage', { stage: filters.stage }); } if (filters.opportunityId) { qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId }); } if (filters.bidManagerId) { qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId }); } if (filters.contractingEntity) { qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` }); } if (filters.deadlineFrom) { qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom }); } if (filters.deadlineTo) { qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo }); } if (filters.minBudget !== undefined) { qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget }); } if (filters.maxBudget !== undefined) { qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget }); } if (filters.search) { qb.andWhere( '(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)', { search: `%${filters.search}%` } ); } const skip = (page - 1) * limit; qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Actualizar licitación */ async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise { const bid = await this.findById(ctx, id); if (!bid) return null; // Calcular puntuación final si hay scores let finalScore = data.finalScore ?? bid.finalScore; const techScore = data.technicalScore ?? bid.technicalScore; const econScore = data.economicScore ?? bid.economicScore; const techWeight = data.technicalWeight ?? bid.technicalWeight; const econWeight = data.economicWeight ?? bid.economicWeight; if (techScore !== undefined && econScore !== undefined) { finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100); } Object.assign(bid, { ...data, finalScore, updatedBy: ctx.userId, }); return this.repository.save(bid); } /** * Cambiar estado */ async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise { const bid = await this.findById(ctx, id); if (!bid) return null; bid.status = status; bid.updatedBy = ctx.userId; return this.repository.save(bid); } /** * Cambiar etapa */ async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise { const bid = await this.findById(ctx, id); if (!bid) return null; bid.stage = stage; bid.updatedBy = ctx.userId; return this.repository.save(bid); } /** * Marcar como presentada */ async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise { const bid = await this.findById(ctx, id); if (!bid) return null; bid.status = 'submitted'; bid.stage = 'post_submission'; bid.ourProposalAmount = proposalAmount; bid.updatedBy = ctx.userId; return this.repository.save(bid); } /** * Registrar resultado */ async recordResult( ctx: ServiceContext, id: string, won: boolean, details: { winnerName?: string; winningAmount?: number; rankingPosition?: number; rejectionReason?: string; lessonsLearned?: string; } ): Promise { const bid = await this.findById(ctx, id); if (!bid) return null; bid.status = won ? 'awarded' : 'rejected'; bid.awardDate = new Date(); Object.assign(bid, details); bid.updatedBy = ctx.userId; return this.repository.save(bid); } /** * Convertir a proyecto */ async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise { const bid = await this.findById(ctx, id); if (!bid || bid.status !== 'awarded') return null; bid.convertedToProjectId = projectId; bid.convertedAt = new Date(); bid.updatedBy = ctx.userId; return this.repository.save(bid); } /** * Obtener próximas fechas límite */ async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise { const now = new Date(); const future = new Date(); future.setDate(future.getDate() + days); return this.repository.find({ where: { tenantId: ctx.tenantId, deletedAt: undefined, status: In(['draft', 'preparation', 'review', 'approved']), submissionDeadline: Between(now, future), }, relations: ['opportunity', 'bidManager'], order: { submissionDeadline: 'ASC' }, }); } /** * Obtener estadísticas */ async getStats(ctx: ServiceContext, year?: number): Promise { const currentYear = year || new Date().getFullYear(); const startDate = new Date(currentYear, 0, 1); const endDate = new Date(currentYear, 11, 31); const total = await this.repository .createQueryBuilder('b') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.deleted_at IS NULL') .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .getCount(); const byStatus = await this.repository .createQueryBuilder('b') .select('b.status', 'status') .addSelect('COUNT(*)', 'count') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.deleted_at IS NULL') .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .groupBy('b.status') .getRawMany(); const byType = await this.repository .createQueryBuilder('b') .select('b.bid_type', 'bidType') .addSelect('COUNT(*)', 'count') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.deleted_at IS NULL') .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .groupBy('b.bid_type') .getRawMany(); const valueStats = await this.repository .createQueryBuilder('b') .select('SUM(b.base_budget)', 'totalBudget') .addSelect('SUM(b.our_proposal_amount)', 'totalProposed') .addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.deleted_at IS NULL') .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .getRawOne(); const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0; const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0; const closedCount = parseInt(awardedCount) + parseInt(rejectedCount); return { year: currentYear, total, byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })), totalBudget: parseFloat(valueStats?.totalBudget) || 0, totalProposed: parseFloat(valueStats?.totalProposed) || 0, totalWon: parseFloat(valueStats?.totalWon) || 0, winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0, }; } /** * Soft delete */ async softDelete(ctx: ServiceContext, id: string): Promise { const result = await this.repository.update( { id, tenantId: ctx.tenantId }, { deletedAt: new Date(), updatedBy: ctx.userId } ); return (result.affected || 0) > 0; } } export interface BidStats { year: number; total: number; byStatus: { status: BidStatus; count: number }[]; byType: { bidType: BidType; count: number }[]; totalBudget: number; totalProposed: number; totalWon: number; winRate: number; }