- 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>
383 lines
11 KiB
TypeScript
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;
|
|
}
|