[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 18:16:51 -06:00
parent 19fcf169c0
commit 3b4bb3d80e
5 changed files with 1790 additions and 1 deletions

View File

@ -252,8 +252,230 @@ export class ConceptoService {
async codeExists(ctx: ServiceContext, code: string): Promise<boolean> {
return this.exists(ctx, { code } as FindOptionsWhere<Concepto>);
}
/**
* Actualizar precio unitario de un concepto
*/
async updateUnitPrice(
ctx: ServiceContext,
id: string,
unitPrice: number
): Promise<Concepto | null> {
return this.update(ctx, id, {
unitPrice,
updatedById: ctx.userId,
});
}
/**
* Obtener conceptos por unidad
*/
async findByUnit(
ctx: ServiceContext,
unitId: string
): Promise<Concepto[]> {
return this.find(ctx, {
where: { unitId } as FindOptionsWhere<Concepto>,
order: { code: 'ASC' },
});
}
/**
* Obtener conceptos compuestos (con sub-conceptos)
*/
async findCompositeConceptos(
ctx: ServiceContext,
page = 1,
limit = 50
): Promise<PaginatedResult<Concepto>> {
return this.findAll(ctx, {
page,
limit,
where: { isComposite: true } as FindOptionsWhere<Concepto>,
});
}
/**
* Obtener conceptos simples (hoja del arbol)
*/
async findSimpleConceptos(
ctx: ServiceContext,
page = 1,
limit = 50
): Promise<PaginatedResult<Concepto>> {
return this.findAll(ctx, {
page,
limit,
where: { isComposite: false } as FindOptionsWhere<Concepto>,
});
}
/**
* Obtener la ruta completa de un concepto (ancestros)
*/
async getConceptoPath(
ctx: ServiceContext,
id: string
): Promise<Concepto[]> {
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<Concepto | null> {
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<Concepto[]> {
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<number> {
return this.repository.count({
where: {
tenantId: ctx.tenantId,
parentId,
deletedAt: IsNull(),
} as FindOptionsWhere<Concepto>,
});
}
/**
* Importar conceptos desde un array (bulk insert)
*/
async bulkCreate(
ctx: ServiceContext,
conceptos: CreateConceptoDto[]
): Promise<Concepto[]> {
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<number> {
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<ConceptoCatalogStats> {
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;
}

View File

@ -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<PaginatedResult<Presupuesto>> {
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<Presupuesto[]> {
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<BudgetComparison> {
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<BudgetComparison> {
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<Presupuesto> {
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<Presupuesto>;
}
/**
* Obtener resumen de presupuesto
*/
async getSummary(
ctx: ServiceContext,
presupuestoId: string
): Promise<BudgetSummary | null> {
const presupuesto = await this.findWithPartidas(ctx, presupuestoId);
if (!presupuesto) {
return null;
}
const partidasByLevel = new Map<number, PartidaSummary[]>();
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<PaginatedResult<Presupuesto>> {
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<number, PartidaSummary[]>;
}
export interface PresupuestoFilters {
code?: string;
name?: string;
fraccionamientoId?: string;
prototipoId?: string;
isActive?: boolean;
isApproved?: boolean;
minAmount?: number;
maxAmount?: number;
}

View File

@ -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<T> {
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<DeductionType, { count: number; amount: number }>;
byEstimacion: Map<string, { count: number; amount: number }>;
}
/**
* Mapeo de tipo de deduccion a descripcion
*/
const DEDUCTION_TYPE_DESCRIPTIONS: Record<DeductionType, string> = {
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<Retencion>;
private estimacionRepository: Repository<Estimacion>;
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<Retencion | null> {
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<Retencion[]> {
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<Retencion> {
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<CreateDeductivaDto>
): Promise<Retencion | null> {
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<boolean> {
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<Retencion | null> {
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<Retencion | null> {
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<PaginatedResult<Retencion>> {
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<DeductivaSummary> {
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<DeductionType, { count: number; amount: number }>();
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<DeductivaStats> {
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<Retencion[]> {
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<DeductivaWithMetadata | null> {
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<void> {
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;
}

View File

@ -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<CumulativeTotals> {
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<PreviousQuantities> {
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<AmortizationCalculation> {
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<EstimacionWorkflow[]> {
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<Estimacion | null> {
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<Estimacion> = {
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<Estimacion> {
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<EstimacionDocumentData> {
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<EstimacionStats> {
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<AddConceptoDto>
): Promise<EstimacionConcepto | null> {
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<boolean> {
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<Record<EstimateStatus, number>>;
totalSubtotal: number;
totalAdvance: number;
totalRetention: number;
totalTax: number;
totalAmount: number;
}

View File

@ -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';