[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:
parent
19fcf169c0
commit
3b4bb3d80e
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
712
src/modules/estimates/services/deductiva.service.ts
Normal file
712
src/modules/estimates/services/deductiva.service.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user