"use strict"; /** * EstimacionService - Gestión de Estimaciones de Obra * * Gestiona estimaciones periódicas con workflow de aprobación. * Incluye cálculo de anticipos, retenciones e IVA. * * @module Estimates */ Object.defineProperty(exports, "__esModule", { value: true }); exports.EstimacionService = void 0; const base_service_1 = require("../../../shared/services/base.service"); class EstimacionService extends base_service_1.BaseService { conceptoRepository; generadorRepository; workflowRepository; dataSource; constructor(repository, conceptoRepository, generadorRepository, workflowRepository, dataSource) { super(repository); this.conceptoRepository = conceptoRepository; this.generadorRepository = generadorRepository; this.workflowRepository = workflowRepository; this.dataSource = dataSource; } /** * Crear nueva estimación */ async createEstimacion(ctx, data) { const sequenceNumber = await this.getNextSequenceNumber(ctx, data.contratoId); const estimateNumber = await this.generateEstimateNumber(ctx, data.contratoId, sequenceNumber); const estimacion = await this.create(ctx, { ...data, estimateNumber, sequenceNumber, status: 'draft', }); // Registrar en workflow await this.addWorkflowEntry(ctx, estimacion.id, null, 'draft', 'create', 'Estimación creada'); return estimacion; } /** * Obtener siguiente número de secuencia */ async getNextSequenceNumber(ctx, contratoId) { const result = await this.repository .createQueryBuilder('e') .select('MAX(e.sequence_number)', 'maxNumber') .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('e.contrato_id = :contratoId', { contratoId }) .getRawOne(); return (result?.maxNumber || 0) + 1; } /** * Generar número de estimación */ async generateEstimateNumber(_ctx, contratoId, sequenceNumber) { const year = new Date().getFullYear(); return `EST-${year}-${contratoId.substring(0, 8).toUpperCase()}-${sequenceNumber.toString().padStart(3, '0')}`; } /** * Obtener estimaciones por contrato */ async findByContrato(ctx, contratoId, page = 1, limit = 20) { return this.findAll(ctx, { page, limit, where: { contratoId }, }); } /** * Obtener estimaciones con filtros */ async findWithFilters(ctx, filters, page = 1, limit = 20) { const qb = this.repository .createQueryBuilder('e') .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('e.deleted_at IS NULL'); if (filters.contratoId) { qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); } if (filters.fraccionamientoId) { qb.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); } if (filters.status) { qb.andWhere('e.status = :status', { status: filters.status }); } if (filters.periodFrom) { qb.andWhere('e.period_start >= :periodFrom', { periodFrom: filters.periodFrom }); } if (filters.periodTo) { qb.andWhere('e.period_end <= :periodTo', { periodTo: filters.periodTo }); } const skip = (page - 1) * limit; qb.orderBy('e.sequence_number', 'DESC').skip(skip).take(limit); const [data, total] = await qb.getManyAndCount(); return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } /** * Obtener estimación con detalles completos */ async findWithDetails(ctx, id) { return this.repository.findOne({ where: { id, tenantId: ctx.tenantId, deletedAt: null, }, relations: [ 'conceptos', 'conceptos.concepto', 'conceptos.generadores', 'amortizaciones', 'retenciones', 'workflow', ], }); } /** * Agregar concepto a estimación */ async addConcepto(ctx, estimacionId, data) { const estimacion = await this.findById(ctx, estimacionId); if (!estimacion || estimacion.status !== 'draft') { throw new Error('Cannot modify non-draft estimation'); } const concepto = this.conceptoRepository.create({ tenantId: ctx.tenantId, estimacionId, conceptoId: data.conceptoId, contratoPartidaId: data.contratoPartidaId, quantityContract: data.quantityContract || 0, quantityPrevious: data.quantityPrevious || 0, quantityCurrent: data.quantityCurrent, unitPrice: data.unitPrice, notes: data.notes, createdById: ctx.userId, }); const savedConcepto = await this.conceptoRepository.save(concepto); await this.recalculateTotals(ctx, estimacionId); return savedConcepto; } /** * Agregar generador a concepto de estimación */ async addGenerador(ctx, estimacionConceptoId, data) { const concepto = await this.conceptoRepository.findOne({ where: { id: estimacionConceptoId, tenantId: ctx.tenantId }, relations: ['estimacion'], }); if (!concepto) { throw new Error('Concepto not found'); } const generador = this.generadorRepository.create({ tenantId: ctx.tenantId, estimacionConceptoId, generatorNumber: data.generatorNumber, description: data.description, loteId: data.loteId, departamentoId: data.departamentoId, locationDescription: data.locationDescription, quantity: data.quantity, formula: data.formula, photoUrl: data.photoUrl, sketchUrl: data.sketchUrl, status: 'draft', capturedById: ctx.userId, createdById: ctx.userId, }); return this.generadorRepository.save(generador); } /** * Recalcular totales de estimación */ async recalculateTotals(_ctx, estimacionId) { // Ejecutar función de PostgreSQL await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]); } /** * Cambiar estado de estimación */ async changeStatus(ctx, estimacionId, newStatus, action, comments) { const estimacion = await this.findById(ctx, estimacionId); if (!estimacion) { return null; } const validTransitions = { draft: ['submitted'], submitted: ['reviewed', 'rejected'], reviewed: ['approved', 'rejected'], approved: ['invoiced'], invoiced: ['paid'], paid: [], rejected: ['draft'], cancelled: [], }; if (!validTransitions[estimacion.status]?.includes(newStatus)) { throw new Error(`Invalid status transition from ${estimacion.status} to ${newStatus}`); } const updateData = { status: newStatus }; switch (newStatus) { case 'submitted': updateData.submittedAt = new Date(); updateData.submittedById = ctx.userId; break; case 'reviewed': updateData.reviewedAt = new Date(); updateData.reviewedById = ctx.userId; break; case 'approved': updateData.approvedAt = new Date(); updateData.approvedById = ctx.userId; break; case 'invoiced': updateData.invoicedAt = new Date(); break; case 'paid': updateData.paidAt = new Date(); break; } const updated = await this.update(ctx, estimacionId, updateData); // Registrar en workflow await this.addWorkflowEntry(ctx, estimacionId, estimacion.status, newStatus, action, comments); return updated; } /** * Agregar entrada al workflow */ async addWorkflowEntry(ctx, estimacionId, fromStatus, toStatus, action, comments) { await this.workflowRepository.save(this.workflowRepository.create({ tenantId: ctx.tenantId, estimacionId, fromStatus, toStatus, action, comments, performedById: ctx.userId, createdById: ctx.userId, })); } /** * Enviar estimación para revisión */ async submit(ctx, estimacionId) { return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión'); } /** * Revisar estimación */ async review(ctx, estimacionId) { return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada'); } /** * Aprobar estimación */ async approve(ctx, estimacionId) { return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada'); } /** * Rechazar estimación */ async reject(ctx, estimacionId, reason) { return this.changeStatus(ctx, estimacionId, 'rejected', 'reject', reason); } /** * Obtener resumen de estimaciones por contrato */ async getContractSummary(ctx, contratoId) { const result = await this.repository .createQueryBuilder('e') .select([ 'COUNT(*) as total_estimates', 'SUM(CASE WHEN e.status = \'approved\' THEN e.total_amount ELSE 0 END) as total_approved', 'SUM(CASE WHEN e.status = \'paid\' THEN e.total_amount ELSE 0 END) as total_paid', ]) .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('e.contrato_id = :contratoId', { contratoId }) .andWhere('e.deleted_at IS NULL') .getRawOne(); return { totalEstimates: parseInt(result?.total_estimates || '0'), totalApproved: parseFloat(result?.total_approved || '0'), totalPaid: parseFloat(result?.total_paid || '0'), }; } } exports.EstimacionService = EstimacionService; //# sourceMappingURL=estimacion.service.js.map