295 lines
10 KiB
JavaScript
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
|