erp-construccion-backend/dist/modules/estimates/services/estimacion.service.js

295 lines
10 KiB
JavaScript

"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