From 3b4bb3d80e10950b0ccb43142221463b525b0ce4 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 18:16:51 -0600 Subject: [PATCH] [ERP-CONSTRUCCION] feat: Enhance budgets and estimates services Budgets: - concepto.service: Add bulk operations, tree navigation, catalog stats - presupuesto.service: Add version comparison, search, duplicate, summary Estimates: - estimacion.service: Add cumulative totals, document generation, workflow - deductiva.service: New service for penalty/deduction management Co-Authored-By: Claude Opus 4.5 --- .../budgets/services/concepto.service.ts | 224 +++++- .../budgets/services/presupuesto.service.ts | 459 +++++++++++ .../estimates/services/deductiva.service.ts | 712 ++++++++++++++++++ .../estimates/services/estimacion.service.ts | 395 ++++++++++ src/modules/estimates/services/index.ts | 1 + 5 files changed, 1790 insertions(+), 1 deletion(-) create mode 100644 src/modules/estimates/services/deductiva.service.ts diff --git a/src/modules/budgets/services/concepto.service.ts b/src/modules/budgets/services/concepto.service.ts index b42f5fc..4dcc7f5 100644 --- a/src/modules/budgets/services/concepto.service.ts +++ b/src/modules/budgets/services/concepto.service.ts @@ -252,8 +252,230 @@ export class ConceptoService { async codeExists(ctx: ServiceContext, code: string): Promise { return this.exists(ctx, { code } as FindOptionsWhere); } + + /** + * Actualizar precio unitario de un concepto + */ + async updateUnitPrice( + ctx: ServiceContext, + id: string, + unitPrice: number + ): Promise { + return this.update(ctx, id, { + unitPrice, + updatedById: ctx.userId, + }); + } + + /** + * Obtener conceptos por unidad + */ + async findByUnit( + ctx: ServiceContext, + unitId: string + ): Promise { + return this.find(ctx, { + where: { unitId } as FindOptionsWhere, + order: { code: 'ASC' }, + }); + } + + /** + * Obtener conceptos compuestos (con sub-conceptos) + */ + async findCompositeConceptos( + ctx: ServiceContext, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { isComposite: true } as FindOptionsWhere, + }); + } + + /** + * Obtener conceptos simples (hoja del arbol) + */ + async findSimpleConceptos( + ctx: ServiceContext, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { isComposite: false } as FindOptionsWhere, + }); + } + + /** + * Obtener la ruta completa de un concepto (ancestros) + */ + async getConceptoPath( + ctx: ServiceContext, + id: string + ): Promise { + const concepto = await this.findById(ctx, id); + if (!concepto) { + return []; + } + + const path: Concepto[] = [concepto]; + let current = concepto; + + while (current.parentId) { + const parent = await this.findById(ctx, current.parentId); + if (!parent) break; + path.unshift(parent); + current = parent; + } + + return path; + } + + /** + * Mover concepto a otro padre + */ + async moveConcepto( + ctx: ServiceContext, + id: string, + newParentId: string | null + ): Promise { + const concepto = await this.findById(ctx, id); + if (!concepto) { + return null; + } + + let level = 0; + let path = concepto.code; + + if (newParentId) { + const newParent = await this.findById(ctx, newParentId); + if (!newParent) { + throw new Error('New parent concept not found'); + } + level = newParent.level + 1; + path = `${newParent.path}/${concepto.code}`; + } + + return this.update(ctx, id, { + parentId: newParentId, + level, + path, + updatedById: ctx.userId, + }); + } + + /** + * Obtener todos los descendientes de un concepto + */ + async getAllDescendants( + ctx: ServiceContext, + id: string + ): Promise { + const concepto = await this.findById(ctx, id); + if (!concepto || !concepto.path) { + return []; + } + + return this.repository + .createQueryBuilder('c') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL') + .andWhere('c.path LIKE :pathPrefix', { pathPrefix: `${concepto.path}/%` }) + .orderBy('c.path', 'ASC') + .getMany(); + } + + /** + * Contar hijos directos de un concepto + */ + async countChildren(ctx: ServiceContext, parentId: string): Promise { + return this.repository.count({ + where: { + tenantId: ctx.tenantId, + parentId, + deletedAt: IsNull(), + } as FindOptionsWhere, + }); + } + + /** + * Importar conceptos desde un array (bulk insert) + */ + async bulkCreate( + ctx: ServiceContext, + conceptos: CreateConceptoDto[] + ): Promise { + const created: Concepto[] = []; + + for (const data of conceptos) { + const concepto = await this.createConcepto(ctx, data); + created.push(concepto); + } + + return created; + } + + /** + * Actualizar precios en lote + */ + async bulkUpdatePrices( + ctx: ServiceContext, + updates: Array<{ id: string; unitPrice: number }> + ): Promise { + let updatedCount = 0; + + for (const update of updates) { + const result = await this.updateUnitPrice(ctx, update.id, update.unitPrice); + if (result) { + updatedCount++; + } + } + + return updatedCount; + } + + /** + * Obtener estadisticas del catalogo de conceptos + */ + async getCatalogStats(ctx: ServiceContext): Promise { + const result = await this.repository + .createQueryBuilder('c') + .select([ + 'COUNT(*) as total_conceptos', + 'COUNT(CASE WHEN c.is_composite = true THEN 1 END) as composite_count', + 'COUNT(CASE WHEN c.is_composite = false THEN 1 END) as simple_count', + 'COUNT(CASE WHEN c.parent_id IS NULL THEN 1 END) as root_count', + 'MAX(c.level) as max_depth', + 'AVG(c.unit_price) as avg_unit_price', + ]) + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL') + .getRawOne(); + + return { + totalConceptos: parseInt(result?.total_conceptos || '0'), + compositeCount: parseInt(result?.composite_count || '0'), + simpleCount: parseInt(result?.simple_count || '0'), + rootCount: parseInt(result?.root_count || '0'), + maxDepth: parseInt(result?.max_depth || '0'), + avgUnitPrice: parseFloat(result?.avg_unit_price || '0'), + }; + } } -interface ConceptoNode extends Concepto { +export interface ConceptoNode extends Concepto { children: ConceptoNode[]; } + +export interface ConceptoCatalogStats { + totalConceptos: number; + compositeCount: number; + simpleCount: number; + rootCount: number; + maxDepth: number; + avgUnitPrice: number; +} diff --git a/src/modules/budgets/services/presupuesto.service.ts b/src/modules/budgets/services/presupuesto.service.ts index f52f7a9..01d6b65 100644 --- a/src/modules/budgets/services/presupuesto.service.ts +++ b/src/modules/budgets/services/presupuesto.service.ts @@ -376,4 +376,463 @@ export class PresupuestoService { approvedById: ctx.userId, }); } + + /** + * Obtener presupuestos por prototipo + */ + async findByPrototipo( + ctx: ServiceContext, + prototipoId: string, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const [data, total] = await this.repository.findAndCount({ + where: { + tenantId: ctx.tenantId, + prototipoId, + isActive: true, + deletedAt: IsNull(), + }, + order: { version: 'DESC' }, + skip, + take: limit, + }); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Obtener historial de versiones de un presupuesto + */ + async getVersionHistory( + ctx: ServiceContext, + code: string + ): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + code, + deletedAt: IsNull(), + }, + order: { version: 'DESC' }, + }); + } + + /** + * Comparar dos presupuestos (versiones o diferentes presupuestos) + * Retorna las diferencias entre partidas + */ + async comparePresupuestos( + ctx: ServiceContext, + presupuestoId1: string, + presupuestoId2: string + ): Promise { + const [budget1, budget2] = await Promise.all([ + this.findWithPartidas(ctx, presupuestoId1), + this.findWithPartidas(ctx, presupuestoId2), + ]); + + if (!budget1 || !budget2) { + throw new Error('One or both budgets not found'); + } + + const partidas1Map = new Map( + budget1.partidas.map((p) => [p.conceptoId, p]) + ); + const partidas2Map = new Map( + budget2.partidas.map((p) => [p.conceptoId, p]) + ); + + const comparison: PartidaComparison[] = []; + + // Items in budget1 + for (const [conceptoId, partida1] of partidas1Map) { + const partida2 = partidas2Map.get(conceptoId); + if (partida2) { + // Item exists in both + const quantityDiff = Number(partida2.quantity) - Number(partida1.quantity); + const priceDiff = Number(partida2.unitPrice) - Number(partida1.unitPrice); + const total1 = Number(partida1.quantity) * Number(partida1.unitPrice); + const total2 = Number(partida2.quantity) * Number(partida2.unitPrice); + + comparison.push({ + conceptoId, + conceptoCode: partida1.concepto?.code, + conceptoName: partida1.concepto?.name, + status: quantityDiff === 0 && priceDiff === 0 ? 'unchanged' : 'modified', + budget1: { + quantity: Number(partida1.quantity), + unitPrice: Number(partida1.unitPrice), + total: total1, + }, + budget2: { + quantity: Number(partida2.quantity), + unitPrice: Number(partida2.unitPrice), + total: total2, + }, + difference: { + quantity: quantityDiff, + unitPrice: priceDiff, + total: total2 - total1, + percentageChange: total1 > 0 ? ((total2 - total1) / total1) * 100 : 0, + }, + }); + } else { + // Item only in budget1 (removed in budget2) + const total1 = Number(partida1.quantity) * Number(partida1.unitPrice); + comparison.push({ + conceptoId, + conceptoCode: partida1.concepto?.code, + conceptoName: partida1.concepto?.name, + status: 'removed', + budget1: { + quantity: Number(partida1.quantity), + unitPrice: Number(partida1.unitPrice), + total: total1, + }, + budget2: null, + difference: { + quantity: -Number(partida1.quantity), + unitPrice: -Number(partida1.unitPrice), + total: -total1, + percentageChange: -100, + }, + }); + } + } + + // Items only in budget2 (added) + for (const [conceptoId, partida2] of partidas2Map) { + if (!partidas1Map.has(conceptoId)) { + const total2 = Number(partida2.quantity) * Number(partida2.unitPrice); + comparison.push({ + conceptoId, + conceptoCode: partida2.concepto?.code, + conceptoName: partida2.concepto?.name, + status: 'added', + budget1: null, + budget2: { + quantity: Number(partida2.quantity), + unitPrice: Number(partida2.unitPrice), + total: total2, + }, + difference: { + quantity: Number(partida2.quantity), + unitPrice: Number(partida2.unitPrice), + total: total2, + percentageChange: 100, + }, + }); + } + } + + // Calculate summary + const totalBudget1 = Number(budget1.totalAmount); + const totalBudget2 = Number(budget2.totalAmount); + + return { + budget1: { + id: budget1.id, + code: budget1.code, + name: budget1.name, + version: budget1.version, + totalAmount: totalBudget1, + partidasCount: budget1.partidas.length, + }, + budget2: { + id: budget2.id, + code: budget2.code, + name: budget2.name, + version: budget2.version, + totalAmount: totalBudget2, + partidasCount: budget2.partidas.length, + }, + summary: { + totalDifference: totalBudget2 - totalBudget1, + percentageChange: totalBudget1 > 0 ? ((totalBudget2 - totalBudget1) / totalBudget1) * 100 : 0, + addedItems: comparison.filter((c) => c.status === 'added').length, + removedItems: comparison.filter((c) => c.status === 'removed').length, + modifiedItems: comparison.filter((c) => c.status === 'modified').length, + unchangedItems: comparison.filter((c) => c.status === 'unchanged').length, + }, + partidas: comparison, + }; + } + + /** + * Comparar versiones de un mismo presupuesto + */ + async compareVersions( + ctx: ServiceContext, + code: string, + version1: number, + version2: number + ): Promise { + const budgets = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + code, + version: version1, + deletedAt: IsNull(), + }, + }); + + const budget1 = budgets.find((b) => b.version === version1); + const budget2 = await this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + code, + version: version2, + deletedAt: IsNull(), + }, + }); + + if (!budget1 || !budget2) { + throw new Error('One or both budget versions not found'); + } + + return this.comparePresupuestos(ctx, budget1.id, budget2.id); + } + + /** + * Duplicar presupuesto (sin versionar, crea uno nuevo) + */ + async duplicate( + ctx: ServiceContext, + presupuestoId: string, + newCode: string, + newName: string + ): Promise { + const original = await this.findWithPartidas(ctx, presupuestoId); + if (!original) { + throw new Error('Presupuesto not found'); + } + + // Create new budget + const newBudget = await this.create(ctx, { + code: newCode, + name: newName, + description: original.description, + fraccionamientoId: original.fraccionamientoId, + prototipoId: original.prototipoId, + currencyId: original.currencyId, + version: 1, + isActive: true, + totalAmount: 0, + }); + + // Copy partidas + for (const partida of original.partidas) { + await this.partidaRepository.save( + this.partidaRepository.create({ + tenantId: ctx.tenantId, + presupuestoId: newBudget.id, + conceptoId: partida.conceptoId, + quantity: partida.quantity, + unitPrice: partida.unitPrice, + sequence: partida.sequence, + createdById: ctx.userId, + }) + ); + } + + await this.recalculateTotal(ctx, newBudget.id); + + return this.findById(ctx, newBudget.id) as Promise; + } + + /** + * Obtener resumen de presupuesto + */ + async getSummary( + ctx: ServiceContext, + presupuestoId: string + ): Promise { + const presupuesto = await this.findWithPartidas(ctx, presupuestoId); + if (!presupuesto) { + return null; + } + + const partidasByLevel = new Map(); + + for (const partida of presupuesto.partidas) { + const level = partida.concepto?.level || 0; + if (!partidasByLevel.has(level)) { + partidasByLevel.set(level, []); + } + partidasByLevel.get(level)!.push({ + conceptoId: partida.conceptoId, + conceptoCode: partida.concepto?.code || '', + conceptoName: partida.concepto?.name || '', + quantity: Number(partida.quantity), + unitPrice: Number(partida.unitPrice), + total: Number(partida.quantity) * Number(partida.unitPrice), + }); + } + + return { + id: presupuesto.id, + code: presupuesto.code, + name: presupuesto.name, + version: presupuesto.version, + totalAmount: Number(presupuesto.totalAmount), + partidasCount: presupuesto.partidas.length, + isApproved: presupuesto.approvedAt !== null, + approvedAt: presupuesto.approvedAt, + createdAt: presupuesto.createdAt, + partidasByLevel: Object.fromEntries(partidasByLevel), + }; + } + + /** + * Buscar presupuestos con filtros avanzados + */ + async search( + ctx: ServiceContext, + filters: PresupuestoFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('p.code ILIKE :code', { code: `%${filters.code}%` }); + } + if (filters.name) { + qb.andWhere('p.name ILIKE :name', { name: `%${filters.name}%` }); + } + if (filters.fraccionamientoId) { + qb.andWhere('p.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + if (filters.prototipoId) { + qb.andWhere('p.prototipo_id = :prototipoId', { + prototipoId: filters.prototipoId, + }); + } + if (filters.isActive !== undefined) { + qb.andWhere('p.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.isApproved !== undefined) { + if (filters.isApproved) { + qb.andWhere('p.approved_at IS NOT NULL'); + } else { + qb.andWhere('p.approved_at IS NULL'); + } + } + if (filters.minAmount !== undefined) { + qb.andWhere('p.total_amount >= :minAmount', { minAmount: filters.minAmount }); + } + if (filters.maxAmount !== undefined) { + qb.andWhere('p.total_amount <= :maxAmount', { maxAmount: filters.maxAmount }); + } + + const skip = (page - 1) * limit; + qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} + +// Types for comparison results +export interface BudgetComparisonItem { + quantity: number; + unitPrice: number; + total: number; +} + +export interface PartidaComparison { + conceptoId: string; + conceptoCode?: string; + conceptoName?: string; + status: 'added' | 'removed' | 'modified' | 'unchanged'; + budget1: BudgetComparisonItem | null; + budget2: BudgetComparisonItem | null; + difference: { + quantity: number; + unitPrice: number; + total: number; + percentageChange: number; + }; +} + +export interface BudgetComparison { + budget1: { + id: string; + code: string; + name: string; + version: number; + totalAmount: number; + partidasCount: number; + }; + budget2: { + id: string; + code: string; + name: string; + version: number; + totalAmount: number; + partidasCount: number; + }; + summary: { + totalDifference: number; + percentageChange: number; + addedItems: number; + removedItems: number; + modifiedItems: number; + unchangedItems: number; + }; + partidas: PartidaComparison[]; +} + +export interface PartidaSummary { + conceptoId: string; + conceptoCode: string; + conceptoName: string; + quantity: number; + unitPrice: number; + total: number; +} + +export interface BudgetSummary { + id: string; + code: string; + name: string; + version: number; + totalAmount: number; + partidasCount: number; + isApproved: boolean; + approvedAt: Date | null; + createdAt: Date; + partidasByLevel: Record; +} + +export interface PresupuestoFilters { + code?: string; + name?: string; + fraccionamientoId?: string; + prototipoId?: string; + isActive?: boolean; + isApproved?: boolean; + minAmount?: number; + maxAmount?: number; } diff --git a/src/modules/estimates/services/deductiva.service.ts b/src/modules/estimates/services/deductiva.service.ts new file mode 100644 index 0000000..d24cd0a --- /dev/null +++ b/src/modules/estimates/services/deductiva.service.ts @@ -0,0 +1,712 @@ +/** + * DeductivaService - Gestion de Deductivas de Estimaciones + * + * Gestiona deductivas (penalizaciones) aplicadas a estimaciones: + * - Trabajo no realizado + * - Defectos de calidad + * - Penalizaciones contractuales + * - Ajustes de cantidad + * + * Las deductivas se almacenan como retenciones con retention_type = 'penalty' + * + * @module Estimates + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { Retencion, RetentionType } from '../entities/retencion.entity'; +import { Estimacion } from '../entities/estimacion.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * Tipos de deductiva + */ +export type DeductionType = + | 'work_not_done' + | 'quality_defect' + | 'penalty' + | 'quantity_adjustment' + | 'other'; + +/** + * DTO para crear una deductiva + */ +export interface CreateDeductivaDto { + estimacionId: string; + deductionType: DeductionType; + description: string; + amount: number; + percentage?: number; + relatedConceptCode?: string; + justification: string; + evidenceUrls?: string[]; + notes?: string; +} + +/** + * DTO para aprobar una deductiva + */ +export interface ApproveDeductivaDto { + approvedAmount?: number; + approvalNotes?: string; +} + +/** + * Filtros para buscar deductivas + */ +export interface DeductivaFilters { + estimacionId?: string; + contratoId?: string; + deductionType?: DeductionType; + isApproved?: boolean; + dateFrom?: Date; + dateTo?: Date; + minAmount?: number; + maxAmount?: number; +} + +/** + * Resumen de deductivas por contrato + */ +export interface DeductivaSummary { + totalDeductivas: number; + totalAmount: number; + totalApproved: number; + totalPending: number; + byType: { + type: DeductionType; + count: number; + amount: number; + }[]; +} + +/** + * Estadisticas de deductivas + */ +export interface DeductivaStats { + totalCount: number; + totalAmount: number; + approvedCount: number; + approvedAmount: number; + pendingCount: number; + pendingAmount: number; + averageAmount: number; + byType: Map; + byEstimacion: Map; +} + +/** + * Mapeo de tipo de deduccion a descripcion + */ +const DEDUCTION_TYPE_DESCRIPTIONS: Record = { + work_not_done: 'Trabajo no realizado', + quality_defect: 'Defecto de calidad', + penalty: 'Penalizacion contractual', + quantity_adjustment: 'Ajuste de cantidad', + other: 'Otro', +}; + +export class DeductivaService { + private retencionRepository: Repository; + private estimacionRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.retencionRepository = dataSource.getRepository(Retencion); + this.estimacionRepository = dataSource.getRepository(Estimacion); + } + + /** + * Busca una deductiva por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.retencionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + retentionType: 'penalty' as RetentionType, + deletedAt: IsNull(), + }, + relations: ['estimacion'], + }); + } + + /** + * Obtener deductivas por estimacion + */ + async findByEstimacion( + ctx: ServiceContext, + estimacionId: string + ): Promise { + return this.retencionRepository.find({ + where: { + tenantId: ctx.tenantId, + estimacionId, + retentionType: 'penalty' as RetentionType, + deletedAt: IsNull(), + }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Crear nueva deductiva + */ + async createDeductiva( + ctx: ServiceContext, + dto: CreateDeductivaDto + ): Promise { + const estimacion = await this.estimacionRepository.findOne({ + where: { + id: dto.estimacionId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!estimacion) { + throw new Error('Estimacion no encontrada'); + } + + if (estimacion.status !== 'draft' && estimacion.status !== 'submitted') { + throw new Error('Solo se pueden agregar deductivas a estimaciones en borrador o enviadas'); + } + + if (dto.amount <= 0) { + throw new Error('El monto de la deductiva debe ser mayor a cero'); + } + + const typeDescription = DEDUCTION_TYPE_DESCRIPTIONS[dto.deductionType] || 'Deductiva'; + const fullDescription = `[${typeDescription}] ${dto.description}`; + + const notesContent = this.buildDeductivaNotesJson(dto); + + const deductiva = this.retencionRepository.create({ + tenantId: ctx.tenantId, + estimacionId: dto.estimacionId, + retentionType: 'penalty' as RetentionType, + description: fullDescription, + percentage: dto.percentage ?? null, + amount: dto.amount, + notes: notesContent, + createdById: ctx.userId ?? null, + }); + + const saved = await this.retencionRepository.save(deductiva); + + await this.recalculateEstimacionTotals(dto.estimacionId); + + return saved; + } + + /** + * Construir notas JSON con metadatos de deductiva + */ + private buildDeductivaNotesJson(dto: CreateDeductivaDto): string { + const metadata = { + deductionType: dto.deductionType, + relatedConceptCode: dto.relatedConceptCode || null, + justification: dto.justification, + evidenceUrls: dto.evidenceUrls || [], + userNotes: dto.notes || null, + approvalStatus: 'pending', + approvedAt: null, + approvedBy: null, + approvedAmount: null, + approvalNotes: null, + }; + return JSON.stringify(metadata); + } + + /** + * Parsear metadatos de notas JSON + */ + private parseDeductivaMetadata(notes: string | null): DeductivaMetadata | null { + if (!notes) return null; + try { + return JSON.parse(notes) as DeductivaMetadata; + } catch { + return null; + } + } + + /** + * Actualizar una deductiva + */ + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const deductiva = await this.findById(ctx, id); + if (!deductiva) { + return null; + } + + const metadata = this.parseDeductivaMetadata(deductiva.notes); + if (metadata?.approvalStatus === 'approved') { + throw new Error('No se puede modificar una deductiva aprobada'); + } + + if (deductiva.estimacion.status !== 'draft' && deductiva.estimacion.status !== 'submitted') { + throw new Error('Solo se pueden modificar deductivas de estimaciones en borrador o enviadas'); + } + + if (data.description) { + const typeDescription = DEDUCTION_TYPE_DESCRIPTIONS[data.deductionType || 'other']; + deductiva.description = `[${typeDescription}] ${data.description}`; + } + + if (data.amount !== undefined) { + if (data.amount <= 0) { + throw new Error('El monto de la deductiva debe ser mayor a cero'); + } + deductiva.amount = data.amount; + } + + if (data.percentage !== undefined) { + deductiva.percentage = data.percentage; + } + + if (metadata) { + if (data.deductionType) metadata.deductionType = data.deductionType; + if (data.justification) metadata.justification = data.justification; + if (data.relatedConceptCode !== undefined) metadata.relatedConceptCode = data.relatedConceptCode || null; + if (data.evidenceUrls) metadata.evidenceUrls = data.evidenceUrls; + if (data.notes !== undefined) metadata.userNotes = data.notes || null; + deductiva.notes = JSON.stringify(metadata); + } + + deductiva.updatedById = ctx.userId ?? null; + const saved = await this.retencionRepository.save(deductiva); + + await this.recalculateEstimacionTotals(deductiva.estimacionId); + + return saved; + } + + /** + * Soft delete de una deductiva + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const deductiva = await this.findById(ctx, id); + if (!deductiva) { + return false; + } + + const metadata = this.parseDeductivaMetadata(deductiva.notes); + if (metadata?.approvalStatus === 'approved') { + throw new Error('No se puede eliminar una deductiva aprobada'); + } + + if (deductiva.estimacion.status !== 'draft' && deductiva.estimacion.status !== 'submitted') { + throw new Error('Solo se pueden eliminar deductivas de estimaciones en borrador o enviadas'); + } + + const estimacionId = deductiva.estimacionId; + + deductiva.deletedAt = new Date(); + deductiva.deletedById = ctx.userId ?? null; + await this.retencionRepository.save(deductiva); + + await this.recalculateEstimacionTotals(estimacionId); + + return true; + } + + /** + * Aprobar una deductiva + */ + async approve( + ctx: ServiceContext, + id: string, + dto?: ApproveDeductivaDto + ): Promise { + const deductiva = await this.findById(ctx, id); + if (!deductiva) { + return null; + } + + const metadata = this.parseDeductivaMetadata(deductiva.notes); + if (!metadata) { + throw new Error('Metadatos de deductiva no encontrados'); + } + + if (metadata.approvalStatus === 'approved') { + throw new Error('La deductiva ya esta aprobada'); + } + + metadata.approvalStatus = 'approved'; + metadata.approvedAt = new Date().toISOString(); + metadata.approvedBy = ctx.userId || null; + + if (dto?.approvedAmount !== undefined) { + if (dto.approvedAmount < 0 || dto.approvedAmount > deductiva.amount) { + throw new Error('El monto aprobado debe estar entre 0 y el monto original'); + } + metadata.approvedAmount = dto.approvedAmount; + deductiva.amount = dto.approvedAmount; + } else { + metadata.approvedAmount = deductiva.amount; + } + + if (dto?.approvalNotes) { + metadata.approvalNotes = dto.approvalNotes; + } + + deductiva.notes = JSON.stringify(metadata); + deductiva.updatedById = ctx.userId ?? null; + const saved = await this.retencionRepository.save(deductiva); + + await this.recalculateEstimacionTotals(deductiva.estimacionId); + + return saved; + } + + /** + * Rechazar una deductiva + */ + async reject( + ctx: ServiceContext, + id: string, + reason: string + ): Promise { + const deductiva = await this.findById(ctx, id); + if (!deductiva) { + return null; + } + + const metadata = this.parseDeductivaMetadata(deductiva.notes); + if (!metadata) { + throw new Error('Metadatos de deductiva no encontrados'); + } + + if (metadata.approvalStatus === 'approved') { + throw new Error('No se puede rechazar una deductiva ya aprobada'); + } + + metadata.approvalStatus = 'rejected'; + metadata.approvedAt = new Date().toISOString(); + metadata.approvedBy = ctx.userId || null; + metadata.approvedAmount = 0; + metadata.approvalNotes = `Rechazada: ${reason}`; + + deductiva.amount = 0; + deductiva.notes = JSON.stringify(metadata); + deductiva.updatedById = ctx.userId ?? null; + const saved = await this.retencionRepository.save(deductiva); + + await this.recalculateEstimacionTotals(deductiva.estimacionId); + + return saved; + } + + /** + * Obtener deductivas con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: DeductivaFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.retencionRepository + .createQueryBuilder('r') + .leftJoin('r.estimacion', 'e') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.retention_type = :retentionType', { retentionType: 'penalty' }) + .andWhere('r.deleted_at IS NULL'); + + if (filters.estimacionId) { + qb.andWhere('r.estimacion_id = :estimacionId', { estimacionId: filters.estimacionId }); + } + if (filters.contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters.dateFrom) { + qb.andWhere('r.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('r.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.minAmount !== undefined) { + qb.andWhere('r.amount >= :minAmount', { minAmount: filters.minAmount }); + } + if (filters.maxAmount !== undefined) { + qb.andWhere('r.amount <= :maxAmount', { maxAmount: filters.maxAmount }); + } + + const skip = (page - 1) * limit; + qb.orderBy('r.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + let filteredData = data; + if (filters.deductionType) { + filteredData = data.filter((d) => { + const metadata = this.parseDeductivaMetadata(d.notes); + return metadata?.deductionType === filters.deductionType; + }); + } + if (filters.isApproved !== undefined) { + filteredData = filteredData.filter((d) => { + const metadata = this.parseDeductivaMetadata(d.notes); + return filters.isApproved + ? metadata?.approvalStatus === 'approved' + : metadata?.approvalStatus !== 'approved'; + }); + } + + return { + data: filteredData, + total: filteredData.length !== data.length ? filteredData.length : total, + page, + limit, + totalPages: Math.ceil((filteredData.length !== data.length ? filteredData.length : total) / limit), + }; + } + + /** + * Obtener resumen de deductivas por contrato + */ + async getContractSummary( + ctx: ServiceContext, + contratoId: string + ): Promise { + const deductivas = await this.retencionRepository + .createQueryBuilder('r') + .leftJoin('r.estimacion', 'e') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('r.retention_type = :retentionType', { retentionType: 'penalty' }) + .andWhere('r.deleted_at IS NULL') + .getMany(); + + const summary: DeductivaSummary = { + totalDeductivas: deductivas.length, + totalAmount: 0, + totalApproved: 0, + totalPending: 0, + byType: [], + }; + + const byTypeMap = new Map(); + + deductivas.forEach((d) => { + const amount = Number(d.amount) || 0; + summary.totalAmount += amount; + + const metadata = this.parseDeductivaMetadata(d.notes); + if (metadata?.approvalStatus === 'approved') { + summary.totalApproved += amount; + } else { + summary.totalPending += amount; + } + + const deductionType = (metadata?.deductionType || 'other') as DeductionType; + const existing = byTypeMap.get(deductionType) || { count: 0, amount: 0 }; + byTypeMap.set(deductionType, { + count: existing.count + 1, + amount: existing.amount + amount, + }); + }); + + summary.byType = Array.from(byTypeMap.entries()).map(([type, data]) => ({ + type, + count: data.count, + amount: data.amount, + })); + + return summary; + } + + /** + * Obtener estadisticas detalladas de deductivas + */ + async getStats( + ctx: ServiceContext, + filters?: DeductivaFilters + ): Promise { + const qb = this.retencionRepository + .createQueryBuilder('r') + .leftJoin('r.estimacion', 'e') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.retention_type = :retentionType', { retentionType: 'penalty' }) + .andWhere('r.deleted_at IS NULL'); + + if (filters?.contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters?.dateFrom) { + qb.andWhere('r.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters?.dateTo) { + qb.andWhere('r.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + const deductivas = await qb.getMany(); + + const stats: DeductivaStats = { + totalCount: deductivas.length, + totalAmount: 0, + approvedCount: 0, + approvedAmount: 0, + pendingCount: 0, + pendingAmount: 0, + averageAmount: 0, + byType: new Map(), + byEstimacion: new Map(), + }; + + deductivas.forEach((d) => { + const amount = Number(d.amount) || 0; + stats.totalAmount += amount; + + const metadata = this.parseDeductivaMetadata(d.notes); + + if (metadata?.approvalStatus === 'approved') { + stats.approvedCount++; + stats.approvedAmount += amount; + } else { + stats.pendingCount++; + stats.pendingAmount += amount; + } + + const deductionType = (metadata?.deductionType || 'other') as DeductionType; + const typeStats = stats.byType.get(deductionType) || { count: 0, amount: 0 }; + stats.byType.set(deductionType, { + count: typeStats.count + 1, + amount: typeStats.amount + amount, + }); + + const estStats = stats.byEstimacion.get(d.estimacionId) || { count: 0, amount: 0 }; + stats.byEstimacion.set(d.estimacionId, { + count: estStats.count + 1, + amount: estStats.amount + amount, + }); + }); + + stats.averageAmount = stats.totalCount > 0 ? stats.totalAmount / stats.totalCount : 0; + + return stats; + } + + /** + * Obtener deductivas pendientes de aprobacion + */ + async getPendingApproval( + ctx: ServiceContext, + contratoId?: string + ): Promise { + const qb = this.retencionRepository + .createQueryBuilder('r') + .leftJoinAndSelect('r.estimacion', 'e') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.retention_type = :retentionType', { retentionType: 'penalty' }) + .andWhere('r.deleted_at IS NULL'); + + if (contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId }); + } + + const all = await qb.orderBy('r.created_at', 'ASC').getMany(); + + return all.filter((d) => { + const metadata = this.parseDeductivaMetadata(d.notes); + return metadata?.approvalStatus === 'pending'; + }); + } + + /** + * Calcular deductiva por porcentaje del subtotal + */ + async calculateByPercentage( + ctx: ServiceContext, + estimacionId: string, + percentage: number + ): Promise<{ calculatedAmount: number; estimacionSubtotal: number }> { + const estimacion = await this.estimacionRepository.findOne({ + where: { + id: estimacionId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!estimacion) { + throw new Error('Estimacion no encontrada'); + } + + if (percentage < 0 || percentage > 100) { + throw new Error('El porcentaje debe estar entre 0 y 100'); + } + + const subtotal = Number(estimacion.subtotal) || 0; + const calculatedAmount = subtotal * (percentage / 100); + + return { + calculatedAmount: Math.round(calculatedAmount * 100) / 100, + estimacionSubtotal: subtotal, + }; + } + + /** + * Obtener deductiva con metadatos parseados + */ + async findByIdWithMetadata( + ctx: ServiceContext, + id: string + ): Promise { + const deductiva = await this.findById(ctx, id); + if (!deductiva) { + return null; + } + + const metadata = this.parseDeductivaMetadata(deductiva.notes); + + return { + ...deductiva, + metadata: metadata || undefined, + }; + } + + /** + * Recalcular totales de estimacion despues de cambios en deductivas + */ + private async recalculateEstimacionTotals(estimacionId: string): Promise { + await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]); + } +} + +/** + * Interface para metadatos de deductiva almacenados en notas + */ +interface DeductivaMetadata { + deductionType: DeductionType; + relatedConceptCode: string | null; + justification: string; + evidenceUrls: string[]; + userNotes: string | null; + approvalStatus: 'pending' | 'approved' | 'rejected'; + approvedAt: string | null; + approvedBy: string | null; + approvedAmount: number | null; + approvalNotes: string | null; +} + +/** + * Interface para deductiva con metadatos parseados + */ +export interface DeductivaWithMetadata extends Retencion { + metadata?: DeductivaMetadata; +} diff --git a/src/modules/estimates/services/estimacion.service.ts b/src/modules/estimates/services/estimacion.service.ts index e817df4..24de4a3 100644 --- a/src/modules/estimates/services/estimacion.service.ts +++ b/src/modules/estimates/services/estimacion.service.ts @@ -501,4 +501,399 @@ export class EstimacionService { totalPaid: parseFloat(result?.total_paid || '0'), }; } + + /** + * Obtener totales acumulados hasta una estimacion + */ + async getCumulativeTotals( + ctx: ServiceContext, + contratoId: string, + upToSequenceNumber: number + ): Promise { + const result = await this.repository + .createQueryBuilder('e') + .select([ + 'SUM(e.subtotal) as cumulative_subtotal', + 'SUM(e.advance_amount) as cumulative_advance', + 'SUM(e.retention_amount) as cumulative_retention', + 'SUM(e.tax_amount) as cumulative_tax', + 'SUM(e.total_amount) as cumulative_total', + ]) + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('e.sequence_number <= :sequenceNumber', { sequenceNumber: upToSequenceNumber }) + .andWhere('e.deleted_at IS NULL') + .andWhere('e.status NOT IN (:...excludedStatuses)', { + excludedStatuses: ['cancelled', 'rejected'], + }) + .getRawOne(); + + return { + cumulativeSubtotal: parseFloat(result?.cumulative_subtotal || '0'), + cumulativeAdvance: parseFloat(result?.cumulative_advance || '0'), + cumulativeRetention: parseFloat(result?.cumulative_retention || '0'), + cumulativeTax: parseFloat(result?.cumulative_tax || '0'), + cumulativeTotal: parseFloat(result?.cumulative_total || '0'), + }; + } + + /** + * Obtener cantidades previas acumuladas para un concepto + */ + async getPreviousQuantities( + ctx: ServiceContext, + contratoId: string, + conceptoId: string, + beforeSequenceNumber: number + ): Promise { + const result = await this.conceptoRepository + .createQueryBuilder('ec') + .innerJoin('ec.estimacion', 'e') + .select([ + 'SUM(ec.quantity_current) as total_previous', + 'SUM(ec.amount_current) as total_previous_amount', + ]) + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('ec.concepto_id = :conceptoId', { conceptoId }) + .andWhere('e.sequence_number < :sequenceNumber', { sequenceNumber: beforeSequenceNumber }) + .andWhere('e.deleted_at IS NULL') + .andWhere('e.status NOT IN (:...excludedStatuses)', { + excludedStatuses: ['cancelled', 'rejected'], + }) + .getRawOne(); + + return { + totalQuantity: parseFloat(result?.total_previous || '0'), + totalAmount: parseFloat(result?.total_previous_amount || '0'), + }; + } + + /** + * Calcular amortizacion de anticipo para una estimacion + */ + async calculateAdvanceAmortization( + ctx: ServiceContext, + estimacionId: string, + advancePercentage: number, + maxAdvanceAmount: number + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion) { + throw new Error('Estimacion no encontrada'); + } + + const cumulativeTotals = await this.getCumulativeTotals( + ctx, + estimacion.contratoId, + estimacion.sequenceNumber - 1 + ); + + const currentSubtotal = Number(estimacion.subtotal) || 0; + const calculatedAmortization = currentSubtotal * (advancePercentage / 100); + const previouslyAmortized = cumulativeTotals.cumulativeAdvance; + const remainingToAmortize = maxAdvanceAmount - previouslyAmortized; + const actualAmortization = Math.min(calculatedAmortization, remainingToAmortize); + + return { + calculatedAmortization, + previouslyAmortized, + remainingToAmortize, + actualAmortization, + isFullyAmortized: actualAmortization >= remainingToAmortize, + }; + } + + /** + * Obtener historial de workflow de una estimacion + */ + async getWorkflowHistory( + ctx: ServiceContext, + estimacionId: string + ): Promise { + return this.workflowRepository.find({ + where: { + tenantId: ctx.tenantId, + estimacionId, + }, + order: { performedAt: 'ASC' }, + relations: ['performedBy'], + }); + } + + /** + * Cancelar estimacion + */ + async cancel( + ctx: ServiceContext, + estimacionId: string, + reason: string + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion) { + return null; + } + + if (estimacion.status === 'paid' || estimacion.status === 'invoiced') { + throw new Error('Cannot cancel paid or invoiced estimations'); + } + + const updateData: Partial = { + status: 'cancelled' as EstimateStatus, + }; + + const updated = await this.update(ctx, estimacionId, updateData); + await this.addWorkflowEntry(ctx, estimacionId, estimacion.status, 'cancelled', 'cancel', reason); + + return updated; + } + + /** + * Duplicar estimacion (crear nueva basada en anterior) + */ + async duplicate( + ctx: ServiceContext, + sourceEstimacionId: string, + newPeriodStart: Date, + newPeriodEnd: Date + ): Promise { + const source = await this.findWithDetails(ctx, sourceEstimacionId); + if (!source) { + throw new Error('Estimacion origen no encontrada'); + } + + const newEstimacion = await this.createEstimacion(ctx, { + contratoId: source.contratoId, + fraccionamientoId: source.fraccionamientoId, + periodStart: newPeriodStart, + periodEnd: newPeriodEnd, + notes: `Duplicada de ${source.estimateNumber}`, + }); + + if (source.conceptos && source.conceptos.length > 0) { + for (const concepto of source.conceptos) { + const previousQuantities = await this.getPreviousQuantities( + ctx, + source.contratoId, + concepto.conceptoId, + newEstimacion.sequenceNumber + ); + + await this.addConcepto(ctx, newEstimacion.id, { + conceptoId: concepto.conceptoId, + contratoPartidaId: concepto.contratoPartidaId ?? undefined, + quantityContract: Number(concepto.quantityContract) || 0, + quantityPrevious: previousQuantities.totalQuantity, + quantityCurrent: 0, + unitPrice: Number(concepto.unitPrice) || 0, + notes: concepto.notes ?? undefined, + }); + } + } + + return newEstimacion; + } + + /** + * Generar datos para documento de estimacion + */ + async generateDocumentData( + ctx: ServiceContext, + estimacionId: string + ): Promise { + const estimacion = await this.findWithDetails(ctx, estimacionId); + if (!estimacion) { + throw new Error('Estimacion no encontrada'); + } + + const cumulativeTotals = await this.getCumulativeTotals( + ctx, + estimacion.contratoId, + estimacion.sequenceNumber + ); + + const previousTotals = await this.getCumulativeTotals( + ctx, + estimacion.contratoId, + estimacion.sequenceNumber - 1 + ); + + return { + estimacion, + currentPeriod: { + subtotal: Number(estimacion.subtotal) || 0, + advanceAmount: Number(estimacion.advanceAmount) || 0, + retentionAmount: Number(estimacion.retentionAmount) || 0, + taxAmount: Number(estimacion.taxAmount) || 0, + totalAmount: Number(estimacion.totalAmount) || 0, + }, + previousAccumulated: previousTotals, + totalAccumulated: cumulativeTotals, + }; + } + + /** + * Obtener estadisticas de estimaciones por periodo + */ + async getEstimacionStats( + ctx: ServiceContext, + filters: EstimacionFilters + ): Promise { + 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.periodFrom) { + qb.andWhere('e.period_start >= :periodFrom', { periodFrom: filters.periodFrom }); + } + if (filters.periodTo) { + qb.andWhere('e.period_end <= :periodTo', { periodTo: filters.periodTo }); + } + + const estimaciones = await qb.getMany(); + + const stats: EstimacionStats = { + totalCount: estimaciones.length, + byStatus: {}, + totalSubtotal: 0, + totalAdvance: 0, + totalRetention: 0, + totalTax: 0, + totalAmount: 0, + }; + + estimaciones.forEach((e) => { + stats.byStatus[e.status] = (stats.byStatus[e.status] || 0) + 1; + stats.totalSubtotal += Number(e.subtotal) || 0; + stats.totalAdvance += Number(e.advanceAmount) || 0; + stats.totalRetention += Number(e.retentionAmount) || 0; + stats.totalTax += Number(e.taxAmount) || 0; + stats.totalAmount += Number(e.totalAmount) || 0; + }); + + return stats; + } + + /** + * Actualizar concepto de estimacion + */ + async updateConcepto( + ctx: ServiceContext, + conceptoId: string, + data: Partial + ): Promise { + const concepto = await this.conceptoRepository.findOne({ + where: { id: conceptoId, tenantId: ctx.tenantId }, + relations: ['estimacion'], + }); + + if (!concepto) { + return null; + } + + if (concepto.estimacion.status !== 'draft') { + throw new Error('Cannot modify conceptos of non-draft estimations'); + } + + if (data.quantityCurrent !== undefined) { + concepto.quantityCurrent = data.quantityCurrent; + } + if (data.quantityPrevious !== undefined) { + concepto.quantityPrevious = data.quantityPrevious; + } + if (data.unitPrice !== undefined) { + concepto.unitPrice = data.unitPrice; + } + if (data.notes !== undefined) { + concepto.notes = data.notes ?? null; + } + + concepto.updatedById = ctx.userId ?? null; + const saved = await this.conceptoRepository.save(concepto); + await this.recalculateTotals(ctx, concepto.estimacionId); + + return saved; + } + + /** + * Eliminar concepto de estimacion + */ + async removeConcepto(ctx: ServiceContext, conceptoId: string): Promise { + const concepto = await this.conceptoRepository.findOne({ + where: { id: conceptoId, tenantId: ctx.tenantId }, + relations: ['estimacion'], + }); + + if (!concepto) { + return false; + } + + if (concepto.estimacion.status !== 'draft') { + throw new Error('Cannot remove conceptos from non-draft estimations'); + } + + const estimacionId = concepto.estimacionId; + concepto.deletedAt = new Date(); + concepto.deletedById = ctx.userId ?? null; + await this.conceptoRepository.save(concepto); + await this.recalculateTotals(ctx, estimacionId); + + return true; + } +} + +/** + * Interfaces adicionales para resultados + */ +export interface CumulativeTotals { + cumulativeSubtotal: number; + cumulativeAdvance: number; + cumulativeRetention: number; + cumulativeTax: number; + cumulativeTotal: number; +} + +export interface PreviousQuantities { + totalQuantity: number; + totalAmount: number; +} + +export interface AmortizationCalculation { + calculatedAmortization: number; + previouslyAmortized: number; + remainingToAmortize: number; + actualAmortization: number; + isFullyAmortized: boolean; +} + +export interface EstimacionDocumentData { + estimacion: Estimacion; + currentPeriod: { + subtotal: number; + advanceAmount: number; + retentionAmount: number; + taxAmount: number; + totalAmount: number; + }; + previousAccumulated: CumulativeTotals; + totalAccumulated: CumulativeTotals; +} + +export interface EstimacionStats { + totalCount: number; + byStatus: Partial>; + totalSubtotal: number; + totalAdvance: number; + totalRetention: number; + totalTax: number; + totalAmount: number; } diff --git a/src/modules/estimates/services/index.ts b/src/modules/estimates/services/index.ts index 0e9df8d..8652452 100644 --- a/src/modules/estimates/services/index.ts +++ b/src/modules/estimates/services/index.ts @@ -7,3 +7,4 @@ export * from './estimacion.service'; export * from './anticipo.service'; export * from './fondo-garantia.service'; export * from './retencion.service'; +export * from './deductiva.service';