erp-construccion-backend-v2/src/modules/bidding/services/bid.service.ts
Adrian Flores Cortes 99064f5f24 [TS-FIX] fix: Fix TypeScript errors in admin and bidding modules
- Add ServiceContext interface to base.service.ts
- Fix admin module: audit-log, backup, cost-center, system-setting
  - Define ServiceContext and PaginatedResult locally
  - Convert services from extends BaseService to standalone
  - Change PaginatedResult from meta format to flat format
  - Fix controllers to use flat pagination format
- Fix bidding module: bid, bid-budget, opportunity
  - Change PaginatedResult from meta format to flat format
  - Fix controllers to use flat pagination format

Modules with remaining errors: finance, payment-terminals, estimates,
mcp, reports, progress, budgets, ai, hse (563 errors total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:10:00 -06:00

383 lines
11 KiB
TypeScript

/**
* 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<string, any>;
}
export interface UpdateBidDto extends Partial<CreateBidDto> {
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<string, any>;
}
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<Bid>) {}
/**
* Crear licitación
*/
async create(ctx: ServiceContext, data: CreateBidDto): Promise<Bid> {
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<Bid | null> {
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<PaginatedResult<Bid>> {
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<Bid | null> {
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<Bid | null> {
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<Bid | null> {
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<Bid | null> {
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<Bid | null> {
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<Bid | null> {
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<Bid[]> {
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<BidStats> {
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<boolean> {
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;
}