[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> {
|
async codeExists(ctx: ServiceContext, code: string): Promise<boolean> {
|
||||||
return this.exists(ctx, { code } as FindOptionsWhere<Concepto>);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConceptoNode extends Concepto {
|
/**
|
||||||
|
* 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptoNode extends Concepto {
|
||||||
children: ConceptoNode[];
|
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,
|
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'),
|
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 './anticipo.service';
|
||||||
export * from './fondo-garantia.service';
|
export * from './fondo-garantia.service';
|
||||||
export * from './retencion.service';
|
export * from './retencion.service';
|
||||||
|
export * from './deductiva.service';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user