fix(hr): Fix PaginatedResult format in employee and puesto services

- Remove meta wrapper from PaginatedResult returns in findWithFilters and findAll
- Return flat format with total, page, limit, totalPages at top level
- Align with base.service.ts interface definition
- Controllers already updated to access flat pagination structure
This commit is contained in:
Adrian Flores Cortes 2026-01-25 14:52:53 -06:00
parent 873693b1d1
commit c8a01c5f14
47 changed files with 1343 additions and 736 deletions

View File

@ -11,12 +11,17 @@ import { DataSource } from 'typeorm';
import { PresupuestoService, CreatePresupuestoDto, AddPartidaDto, UpdatePartidaDto } from '../services/presupuesto.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Presupuesto } from '../entities/presupuesto.entity';
import { PresupuestoPartida } from '../entities/presupuesto-partida.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* ServiceContext - Contexto local para operaciones de servicio
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de presupuestos
@ -25,14 +30,12 @@ export function createPresupuestoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const presupuestoRepository = dataSource.getRepository(Presupuesto);
const partidaRepository = dataSource.getRepository(PresupuestoPartida);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const presupuestoService = new PresupuestoService(presupuestoRepository, partidaRepository);
const presupuestoService = new PresupuestoService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -73,7 +76,12 @@ export function createPresupuestoController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -7,11 +7,23 @@
* @module Budgets
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { Presupuesto } from '../entities/presupuesto.entity';
import { PresupuestoPartida } from '../entities/presupuesto-partida.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreatePresupuestoDto {
code: string;
name: string;
@ -34,12 +46,101 @@ export interface UpdatePartidaDto {
sequence?: number;
}
export class PresupuestoService extends BaseService<Presupuesto> {
constructor(
repository: Repository<Presupuesto>,
private readonly partidaRepository: Repository<PresupuestoPartida>
) {
super(repository);
export class PresupuestoService {
private repository: Repository<Presupuesto>;
private partidaRepository: Repository<PresupuestoPartida>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Presupuesto);
this.partidaRepository = dataSource.getRepository(PresupuestoPartida);
}
/**
* Busca un presupuesto por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Presupuesto | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
});
}
/**
* Busca todos los presupuestos con paginación
*/
async findAll(
ctx: ServiceContext,
page = 1,
limit = 20
): Promise<PaginatedResult<Presupuesto>> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: {
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
skip,
take: limit,
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Crea un nuevo registro
*/
async create(ctx: ServiceContext, data: Partial<Presupuesto>): Promise<Presupuesto> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
createdById: ctx.userId,
});
return this.repository.save(entity);
}
/**
* Actualiza un registro
*/
async update(
ctx: ServiceContext,
id: string,
data: Partial<Presupuesto>
): Promise<Presupuesto | null> {
const entity = await this.findById(ctx, id);
if (!entity) {
return null;
}
Object.assign(entity, data);
entity.updatedById = ctx.userId || null;
return this.repository.save(entity);
}
/**
* Soft delete de un registro
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const entity = await this.findById(ctx, id);
if (!entity) {
return false;
}
entity.deletedAt = new Date();
entity.deletedById = ctx.userId || null;
await this.repository.save(entity);
return true;
}
/**
@ -66,11 +167,27 @@ export class PresupuestoService extends BaseService<Presupuesto> {
page = 1,
limit = 20
): Promise<PaginatedResult<Presupuesto>> {
return this.findAll(ctx, {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
isActive: true,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
skip,
take: limit,
});
return {
data,
total,
page,
limit,
where: { fraccionamientoId, isActive: true } as any,
});
totalPages: Math.ceil(total / limit),
};
}
/**
@ -84,8 +201,8 @@ export class PresupuestoService extends BaseService<Presupuesto> {
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
relations: ['partidas', 'partidas.concepto'],
});
}
@ -131,8 +248,8 @@ export class PresupuestoService extends BaseService<Presupuesto> {
where: {
id: partidaId,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
});
if (!partida) {
@ -158,7 +275,7 @@ export class PresupuestoService extends BaseService<Presupuesto> {
where: {
id: partidaId,
tenantId: ctx.tenantId,
} as any,
},
});
if (!partida) {
@ -191,7 +308,7 @@ export class PresupuestoService extends BaseService<Presupuesto> {
const total = parseFloat(result?.total || '0');
await this.repository.update(
{ id: presupuestoId },
{ id: presupuestoId } as any,
{ totalAmount: total, updatedById: ctx.userId }
);
}
@ -210,7 +327,7 @@ export class PresupuestoService extends BaseService<Presupuesto> {
// Desactivar versión anterior
await this.repository.update(
{ id: presupuestoId },
{ id: presupuestoId } as any,
{ isActive: false, updatedById: ctx.userId }
);

View File

@ -11,23 +11,25 @@ import { DataSource } from 'typeorm';
import { AnticipoService, CreateAnticipoDto, AnticipoFilters } from '../services/anticipo.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Anticipo } from '../entities/anticipo.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createAnticipoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const anticipoRepository = dataSource.getRepository(Anticipo);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const anticipoService = new AnticipoService(anticipoRepository);
const anticipoService = new AnticipoService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -69,7 +71,12 @@ export function createAnticipoController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -120,7 +127,12 @@ export function createAnticipoController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -18,14 +18,17 @@ import {
} from '../services/estimacion.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Estimacion } from '../entities/estimacion.entity';
import { EstimacionConcepto } from '../entities/estimacion-concepto.entity';
import { Generador } from '../entities/generador.entity';
import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Local interface for service context
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de estimaciones
@ -34,22 +37,12 @@ export function createEstimacionController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const estimacionRepository = dataSource.getRepository(Estimacion);
const conceptoRepository = dataSource.getRepository(EstimacionConcepto);
const generadorRepository = dataSource.getRepository(Generador);
const workflowRepository = dataSource.getRepository(EstimacionWorkflow);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const estimacionService = new EstimacionService(
estimacionRepository,
conceptoRepository,
generadorRepository,
workflowRepository,
dataSource
);
const estimacionService = new EstimacionService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -92,7 +85,12 @@ export function createEstimacionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -11,23 +11,28 @@ import { DataSource } from 'typeorm';
import { FondoGarantiaService, CreateFondoGarantiaDto, ReleaseFondoDto } from '../services/fondo-garantia.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { FondoGarantia } from '../entities/fondo-garantia.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Local interface for service context
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createFondoGarantiaController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const fondoGarantiaRepository = dataSource.getRepository(FondoGarantia);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const fondoGarantiaService = new FondoGarantiaService(fondoGarantiaRepository);
const fondoGarantiaService = new FondoGarantiaService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -60,7 +65,12 @@ export function createFondoGarantiaController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -11,23 +11,28 @@ import { DataSource } from 'typeorm';
import { RetencionService, CreateRetencionDto, ReleaseRetencionDto, RetencionFilters } from '../services/retencion.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Retencion } from '../entities/retencion.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Local interface for service context
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createRetencionController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const retencionRepository = dataSource.getRepository(Retencion);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const retencionService = new RetencionService(retencionRepository);
const retencionService = new RetencionService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -70,7 +75,12 @@ export function createRetencionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -6,10 +6,22 @@
* @module Estimates
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { Anticipo, AdvanceType } from '../entities/anticipo.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateAnticipoDto {
contratoId: string;
advanceType: AdvanceType;
@ -45,9 +57,24 @@ export interface AnticipoStats {
}[];
}
export class AnticipoService extends BaseService<Anticipo> {
constructor(repository: Repository<Anticipo>) {
super(repository);
export class AnticipoService {
private repository: Repository<Anticipo>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Anticipo);
}
/**
* Busca un anticipo por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Anticipo | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
});
}
/**
@ -61,13 +88,13 @@ export class AnticipoService extends BaseService<Anticipo> {
): Promise<PaginatedResult<Anticipo>> {
const qb = this.repository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.contrato_id = :contratoId', { contratoId })
.andWhere('a.deleted_at IS NULL');
.where('a.tenantId = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.contratoId = :contratoId', { contratoId })
.andWhere('a.deletedAt IS NULL');
const skip = (page - 1) * limit;
qb.orderBy('a.advance_date', 'DESC')
.addOrderBy('a.advance_number', 'DESC')
qb.orderBy('a.advanceDate', 'DESC')
.addOrderBy('a.advanceNumber', 'DESC')
.skip(skip)
.take(limit);
@ -75,12 +102,10 @@ export class AnticipoService extends BaseService<Anticipo> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -97,11 +122,57 @@ export class AnticipoService extends BaseService<Anticipo> {
throw new Error('El monto neto no coincide con el cálculo (bruto - impuestos)');
}
return this.create(ctx, {
...dto,
const anticipo = this.repository.create({
tenantId: ctx.tenantId,
contratoId: dto.contratoId,
advanceType: dto.advanceType,
advanceNumber: dto.advanceNumber,
advanceDate: dto.advanceDate,
grossAmount: dto.grossAmount,
taxAmount: dto.taxAmount,
netAmount: dto.netAmount,
amortizationPercentage: dto.amortizationPercentage,
amortizedAmount: 0,
isFullyAmortized: false,
notes: dto.notes,
createdBy: ctx.userId,
});
return this.repository.save(anticipo);
}
/**
* Actualiza un anticipo
*/
async update(
ctx: ServiceContext,
id: string,
data: Partial<Anticipo>
): Promise<Anticipo | null> {
const anticipo = await this.findById(ctx, id);
if (!anticipo) {
return null;
}
Object.assign(anticipo, data);
anticipo.updatedById = ctx.userId;
return this.repository.save(anticipo);
}
/**
* Soft delete de un anticipo
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const anticipo = await this.findById(ctx, id);
if (!anticipo) {
return false;
}
anticipo.deletedAt = new Date();
anticipo.deletedById = ctx.userId;
await this.repository.save(anticipo);
return true;
}
/**
@ -122,23 +193,18 @@ export class AnticipoService extends BaseService<Anticipo> {
throw new Error('El anticipo ya está aprobado');
}
const updateData: Partial<Anticipo> = {
approvedAt: new Date(),
approvedById,
};
anticipo.approvedAt = new Date();
anticipo.approvedById = approvedById;
if (notes) {
updateData.notes = anticipo.notes
anticipo.notes = anticipo.notes
? `${anticipo.notes}\n\nAprobación: ${notes}`
: `Aprobación: ${notes}`;
}
const updated = await this.update(ctx, id, updateData);
if (!updated) {
throw new Error('Error al aprobar anticipo');
}
anticipo.updatedById = ctx.userId;
return updated;
return this.repository.save(anticipo);
}
/**
@ -163,16 +229,11 @@ export class AnticipoService extends BaseService<Anticipo> {
throw new Error('El anticipo ya está marcado como pagado');
}
const updated = await this.update(ctx, id, {
paidAt: paidAt || new Date(),
paymentReference,
});
anticipo.paidAt = paidAt || new Date();
anticipo.paymentReference = paymentReference;
anticipo.updatedById = ctx.userId;
if (!updated) {
throw new Error('Error al marcar anticipo como pagado');
}
return updated;
return this.repository.save(anticipo);
}
/**
@ -184,28 +245,28 @@ export class AnticipoService extends BaseService<Anticipo> {
): Promise<AnticipoStats> {
const qb = this.repository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deleted_at IS NULL');
.where('a.tenantId = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deletedAt IS NULL');
if (filters?.contratoId) {
qb.andWhere('a.contrato_id = :contratoId', { contratoId: filters.contratoId });
qb.andWhere('a.contratoId = :contratoId', { contratoId: filters.contratoId });
}
if (filters?.advanceType) {
qb.andWhere('a.advance_type = :advanceType', { advanceType: filters.advanceType });
qb.andWhere('a.advanceType = :advanceType', { advanceType: filters.advanceType });
}
if (filters?.isFullyAmortized !== undefined) {
qb.andWhere('a.is_fully_amortized = :isFullyAmortized', {
qb.andWhere('a.isFullyAmortized = :isFullyAmortized', {
isFullyAmortized: filters.isFullyAmortized,
});
}
if (filters?.startDate) {
qb.andWhere('a.advance_date >= :startDate', { startDate: filters.startDate });
qb.andWhere('a.advanceDate >= :startDate', { startDate: filters.startDate });
}
if (filters?.endDate) {
qb.andWhere('a.advance_date <= :endDate', { endDate: filters.endDate });
qb.andWhere('a.advanceDate <= :endDate', { endDate: filters.endDate });
}
if (filters?.approvedById) {
qb.andWhere('a.approved_by = :approvedById', { approvedById: filters.approvedById });
qb.andWhere('a.approvedById = :approvedById', { approvedById: filters.approvedById });
}
const anticipos = await qb.getMany();
@ -222,7 +283,7 @@ export class AnticipoService extends BaseService<Anticipo> {
const typeStats = new Map<AdvanceType, { count: number; totalAmount: number }>();
anticipos.forEach((anticipo) => {
anticipos.forEach((anticipo: Anticipo) => {
const netAmount = Number(anticipo.netAmount) || 0;
const amortizedAmount = Number(anticipo.amortizedAmount) || 0;
@ -267,33 +328,33 @@ export class AnticipoService extends BaseService<Anticipo> {
): Promise<PaginatedResult<Anticipo>> {
const qb = this.repository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deleted_at IS NULL');
.where('a.tenantId = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deletedAt IS NULL');
if (filters.contratoId) {
qb.andWhere('a.contrato_id = :contratoId', { contratoId: filters.contratoId });
qb.andWhere('a.contratoId = :contratoId', { contratoId: filters.contratoId });
}
if (filters.advanceType) {
qb.andWhere('a.advance_type = :advanceType', { advanceType: filters.advanceType });
qb.andWhere('a.advanceType = :advanceType', { advanceType: filters.advanceType });
}
if (filters.isFullyAmortized !== undefined) {
qb.andWhere('a.is_fully_amortized = :isFullyAmortized', {
qb.andWhere('a.isFullyAmortized = :isFullyAmortized', {
isFullyAmortized: filters.isFullyAmortized,
});
}
if (filters.startDate) {
qb.andWhere('a.advance_date >= :startDate', { startDate: filters.startDate });
qb.andWhere('a.advanceDate >= :startDate', { startDate: filters.startDate });
}
if (filters.endDate) {
qb.andWhere('a.advance_date <= :endDate', { endDate: filters.endDate });
qb.andWhere('a.advanceDate <= :endDate', { endDate: filters.endDate });
}
if (filters.approvedById) {
qb.andWhere('a.approved_by = :approvedById', { approvedById: filters.approvedById });
qb.andWhere('a.approvedById = :approvedById', { approvedById: filters.approvedById });
}
const skip = (page - 1) * limit;
qb.orderBy('a.advance_date', 'DESC')
.addOrderBy('a.advance_number', 'DESC')
qb.orderBy('a.advanceDate', 'DESC')
.addOrderBy('a.advanceNumber', 'DESC')
.skip(skip)
.take(limit);
@ -301,12 +362,10 @@ export class AnticipoService extends BaseService<Anticipo> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -337,15 +396,10 @@ export class AnticipoService extends BaseService<Anticipo> {
const isFullyAmortized = Math.abs(newAmortizedAmount - netAmount) < 0.01;
const updated = await this.update(ctx, id, {
amortizedAmount: newAmortizedAmount,
isFullyAmortized,
});
anticipo.amortizedAmount = newAmortizedAmount;
anticipo.isFullyAmortized = isFullyAmortized;
anticipo.updatedById = ctx.userId;
if (!updated) {
throw new Error('Error al actualizar amortización');
}
return updated;
return this.repository.save(anticipo);
}
}

View File

@ -1,19 +1,31 @@
/**
* EstimacionService - Gestión de Estimaciones de Obra
* EstimacionService - Gestion de Estimaciones de Obra
*
* Gestiona estimaciones periódicas con workflow de aprobación.
* Incluye cálculo de anticipos, retenciones e IVA.
* Gestiona estimaciones periodicas con workflow de aprobacion.
* Incluye calculo de anticipos, retenciones e IVA.
*
* @module Estimates
*/
import { Repository, DataSource } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { Estimacion, EstimateStatus } from '../entities/estimacion.entity';
import { EstimacionConcepto } from '../entities/estimacion-concepto.entity';
import { Generador } from '../entities/generador.entity';
import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateEstimacionDto {
contratoId: string;
fraccionamientoId: string;
@ -52,19 +64,74 @@ export interface EstimacionFilters {
periodTo?: Date;
}
export class EstimacionService extends BaseService<Estimacion> {
constructor(
repository: Repository<Estimacion>,
private readonly conceptoRepository: Repository<EstimacionConcepto>,
private readonly generadorRepository: Repository<Generador>,
private readonly workflowRepository: Repository<EstimacionWorkflow>,
private readonly dataSource: DataSource
) {
super(repository);
interface ContractEstimateSummary {
totalEstimates: number;
totalApproved: number;
totalPaid: number;
}
export class EstimacionService {
private repository: Repository<Estimacion>;
private conceptoRepository: Repository<EstimacionConcepto>;
private generadorRepository: Repository<Generador>;
private workflowRepository: Repository<EstimacionWorkflow>;
constructor(private readonly dataSource: DataSource) {
this.repository = dataSource.getRepository(Estimacion);
this.conceptoRepository = dataSource.getRepository(EstimacionConcepto);
this.generadorRepository = dataSource.getRepository(Generador);
this.workflowRepository = dataSource.getRepository(EstimacionWorkflow);
}
/**
* Crear nueva estimación
* Busca una estimacion por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Estimacion | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
});
}
/**
* Actualiza una estimacion
*/
async update(
ctx: ServiceContext,
id: string,
data: Partial<Estimacion>
): Promise<Estimacion | null> {
const estimacion = await this.findById(ctx, id);
if (!estimacion) {
return null;
}
Object.assign(estimacion, data);
estimacion.updatedById = ctx.userId;
return this.repository.save(estimacion);
}
/**
* Soft delete de una estimacion
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const estimacion = await this.findById(ctx, id);
if (!estimacion) {
return false;
}
estimacion.deletedAt = new Date();
estimacion.deletedById = ctx.userId;
await this.repository.save(estimacion);
return true;
}
/**
* Crear nueva estimacion
*/
async createEstimacion(
ctx: ServiceContext,
@ -73,21 +140,29 @@ export class EstimacionService extends BaseService<Estimacion> {
const sequenceNumber = await this.getNextSequenceNumber(ctx, data.contratoId);
const estimateNumber = await this.generateEstimateNumber(ctx, data.contratoId, sequenceNumber);
const estimacion = await this.create(ctx, {
...data,
const estimacion = this.repository.create({
tenantId: ctx.tenantId,
contratoId: data.contratoId,
fraccionamientoId: data.fraccionamientoId,
periodStart: data.periodStart,
periodEnd: data.periodEnd,
notes: data.notes,
estimateNumber,
sequenceNumber,
status: 'draft',
status: 'draft' as EstimateStatus,
createdById: ctx.userId,
});
// Registrar en workflow
await this.addWorkflowEntry(ctx, estimacion.id, null, 'draft', 'create', 'Estimación creada');
const savedEstimacion = await this.repository.save(estimacion);
return estimacion;
// Registrar en workflow
await this.addWorkflowEntry(ctx, savedEstimacion.id, null, 'draft', 'create', 'Estimacion creada');
return savedEstimacion;
}
/**
* Obtener siguiente número de secuencia
* Obtener siguiente numero de secuencia
*/
private async getNextSequenceNumber(
ctx: ServiceContext,
@ -104,7 +179,7 @@ export class EstimacionService extends BaseService<Estimacion> {
}
/**
* Generar número de estimación
* Generar numero de estimacion
*/
private async generateEstimateNumber(
_ctx: ServiceContext,
@ -124,11 +199,24 @@ export class EstimacionService extends BaseService<Estimacion> {
page = 1,
limit = 20
): Promise<PaginatedResult<Estimacion>> {
return this.findAll(ctx, {
const qb = this.repository
.createQueryBuilder('e')
.where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('e.contrato_id = :contratoId', { contratoId })
.andWhere('e.deleted_at IS NULL');
const skip = (page - 1) * limit;
qb.orderBy('e.sequence_number', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
total,
page,
limit,
where: { contratoId } as any,
});
totalPages: Math.ceil(total / limit),
};
}
/**
@ -168,25 +256,23 @@ export class EstimacionService extends BaseService<Estimacion> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Obtener estimación con detalles completos
* Obtener estimacion con detalles completos
*/
async findWithDetails(ctx: ServiceContext, id: string): Promise<Estimacion | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
relations: [
'conceptos',
'conceptos.concepto',
@ -199,7 +285,7 @@ export class EstimacionService extends BaseService<Estimacion> {
}
/**
* Agregar concepto a estimación
* Agregar concepto a estimacion
*/
async addConcepto(
ctx: ServiceContext,
@ -231,7 +317,7 @@ export class EstimacionService extends BaseService<Estimacion> {
}
/**
* Agregar generador a concepto de estimación
* Agregar generador a concepto de estimacion
*/
async addGenerador(
ctx: ServiceContext,
@ -239,7 +325,7 @@ export class EstimacionService extends BaseService<Estimacion> {
data: AddGeneradorDto
): Promise<Generador> {
const concepto = await this.conceptoRepository.findOne({
where: { id: estimacionConceptoId, tenantId: ctx.tenantId } as any,
where: { id: estimacionConceptoId, tenantId: ctx.tenantId },
relations: ['estimacion'],
});
@ -268,15 +354,15 @@ export class EstimacionService extends BaseService<Estimacion> {
}
/**
* Recalcular totales de estimación
* Recalcular totales de estimacion
*/
async recalculateTotals(_ctx: ServiceContext, estimacionId: string): Promise<void> {
// Ejecutar función de PostgreSQL
// Ejecutar funcion de PostgreSQL
await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]);
}
/**
* Cambiar estado de estimación
* Cambiar estado de estimacion
*/
async changeStatus(
ctx: ServiceContext,
@ -362,28 +448,28 @@ export class EstimacionService extends BaseService<Estimacion> {
}
/**
* Enviar estimación para revisión
* Enviar estimacion para revision
*/
async submit(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión');
return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revision');
}
/**
* Revisar estimación
* Revisar estimacion
*/
async review(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada');
return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revision completada');
}
/**
* Aprobar estimación
* Aprobar estimacion
*/
async approve(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada');
}
/**
* Rechazar estimación
* Rechazar estimacion
*/
async reject(
ctx: ServiceContext,
@ -416,9 +502,3 @@ export class EstimacionService extends BaseService<Estimacion> {
};
}
}
interface ContractEstimateSummary {
totalEstimates: number;
totalApproved: number;
totalPaid: number;
}

View File

@ -6,10 +6,22 @@
* @module Estimates
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { FondoGarantia } from '../entities/fondo-garantia.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateFondoGarantiaDto {
contratoId: string;
accumulatedAmount?: number;
@ -39,9 +51,103 @@ export interface FondoGarantiaStats {
fondosFullyReleased: number;
}
export class FondoGarantiaService extends BaseService<FondoGarantia> {
constructor(repository: Repository<FondoGarantia>) {
super(repository);
export class FondoGarantiaService {
private repository: Repository<FondoGarantia>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(FondoGarantia);
}
/**
* Busca un fondo de garantía por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<FondoGarantia | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
});
}
/**
* Busca todos los fondos de garantía con paginación
*/
async findAll(
ctx: ServiceContext,
page = 1,
limit = 20
): Promise<PaginatedResult<FondoGarantia>> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: {
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
skip,
take: limit,
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Crea un nuevo fondo de garantía
*/
async create(
ctx: ServiceContext,
data: Partial<FondoGarantia>
): Promise<FondoGarantia> {
const fondo = this.repository.create({
...data,
tenantId: ctx.tenantId,
createdById: ctx.userId ?? null,
});
return this.repository.save(fondo);
}
/**
* Actualiza un fondo de garantía
*/
async update(
ctx: ServiceContext,
id: string,
data: Partial<FondoGarantia>
): Promise<FondoGarantia | null> {
const fondo = await this.findById(ctx, id);
if (!fondo) {
return null;
}
Object.assign(fondo, data);
fondo.updatedById = ctx.userId ?? null;
return this.repository.save(fondo);
}
/**
* Soft delete de un fondo de garantía
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const fondo = await this.findById(ctx, id);
if (!fondo) {
return false;
}
fondo.deletedAt = new Date();
fondo.deletedById = ctx.userId ?? null;
await this.repository.save(fondo);
return true;
}
/**
@ -55,8 +161,8 @@ export class FondoGarantiaService extends BaseService<FondoGarantia> {
where: {
tenantId: ctx.tenantId,
contratoId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
});
}
@ -183,8 +289,8 @@ export class FondoGarantiaService extends BaseService<FondoGarantia> {
const fondos = await this.repository.find({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
});
const stats: FondoGarantiaStats = {
@ -260,16 +366,14 @@ export class FondoGarantiaService extends BaseService<FondoGarantia> {
});
}
const finalTotal = filteredData.length !== data.length ? filteredData.length : total;
return {
data: filteredData,
meta: {
total: filteredData.length !== data.length ? filteredData.length : total,
page,
limit,
totalPages: Math.ceil(
(filteredData.length !== data.length ? filteredData.length : total) / limit
),
},
total: finalTotal,
page,
limit,
totalPages: Math.ceil(finalTotal / limit),
};
}
}

View File

@ -6,10 +6,22 @@
* @module Estimates
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { Retencion, RetentionType } from '../entities/retencion.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateRetencionDto {
estimacionId: string;
retentionType: RetentionType;
@ -34,9 +46,24 @@ export interface RetencionFilters {
dateTo?: Date;
}
export class RetencionService extends BaseService<Retencion> {
constructor(repository: Repository<Retencion>) {
super(repository);
export class RetencionService {
private repository: Repository<Retencion>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Retencion);
}
/**
* Busca una retención por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Retencion | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: IsNull(),
},
});
}
/**
@ -50,8 +77,8 @@ export class RetencionService extends BaseService<Retencion> {
where: {
tenantId: ctx.tenantId,
estimacionId,
deletedAt: null,
} as any,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
}
@ -63,11 +90,55 @@ export class RetencionService extends BaseService<Retencion> {
ctx: ServiceContext,
data: CreateRetencionDto
): Promise<Retencion> {
return this.create(ctx, {
...data,
const retencion = this.repository.create({
tenantId: ctx.tenantId,
estimacionId: data.estimacionId,
retentionType: data.retentionType,
description: data.description,
percentage: data.percentage ?? null,
amount: data.amount,
releaseDate: data.releaseDate ?? null,
notes: data.notes ?? null,
releasedAmount: null,
releasedAt: null,
createdById: ctx.userId ?? null,
});
return this.repository.save(retencion);
}
/**
* Actualiza una retención
*/
async update(
ctx: ServiceContext,
id: string,
data: Partial<Retencion>
): Promise<Retencion | null> {
const retencion = await this.findById(ctx, id);
if (!retencion) {
return null;
}
Object.assign(retencion, data);
retencion.updatedById = ctx.userId ?? null;
return this.repository.save(retencion);
}
/**
* Soft delete de una retención
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const retencion = await this.findById(ctx, id);
if (!retencion) {
return false;
}
retencion.deletedAt = new Date();
retencion.deletedById = ctx.userId ?? null;
await this.repository.save(retencion);
return true;
}
/**
@ -115,12 +186,10 @@ export class RetencionService extends BaseService<Retencion> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -17,12 +17,17 @@ import {
} from '../services/employee.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Employee } from '../entities/employee.entity';
import { EmployeeFraccionamiento } from '../entities/employee-fraccionamiento.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Local service context interface
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de empleados
@ -31,14 +36,12 @@ export function createEmployeeController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const employeeRepository = dataSource.getRepository(Employee);
const asignacionRepository = dataSource.getRepository(EmployeeFraccionamiento);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const employeeService = new EmployeeService(employeeRepository, asignacionRepository);
const employeeService = new EmployeeService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -81,7 +84,12 @@ export function createEmployeeController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -11,11 +11,17 @@ import { DataSource } from 'typeorm';
import { PuestoService, CreatePuestoDto, UpdatePuestoDto, PuestoFilters } from '../services/puesto.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Puesto } from '../entities/puesto.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Local service context interface
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de puestos
@ -24,13 +30,12 @@ export function createPuestoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const puestoRepository = dataSource.getRepository(Puesto);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const puestoService = new PuestoService(puestoRepository);
const puestoService = new PuestoService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -71,7 +76,12 @@ export function createPuestoController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -129,12 +129,10 @@ export class EmployeeService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -71,12 +71,10 @@ export class PuestoService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -35,7 +35,14 @@ import { QuejaAmbiental } from '../entities/queja-ambiental.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de gestión ambiental
@ -103,7 +110,12 @@ export function createAmbientalController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -163,7 +175,12 @@ export function createAmbientalController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -284,7 +301,12 @@ export function createAmbientalController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -417,7 +439,12 @@ export function createAmbientalController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -500,7 +527,12 @@ export function createAmbientalController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -20,7 +20,14 @@ import { Capacitacion } from '../entities/capacitacion.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de capacitaciones
@ -76,7 +83,12 @@ export function createCapacitacionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -29,7 +29,14 @@ import { EppMovimiento } from '../entities/epp-movimiento.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de EPP
@ -101,7 +108,12 @@ export function createEppController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -228,7 +240,12 @@ export function createEppController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -26,7 +26,14 @@ import { IncidenteAccion } from '../entities/incidente-accion.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de incidentes
@ -91,7 +98,12 @@ export function createIncidenteController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -25,7 +25,14 @@ import { AlertaIndicador } from '../entities/alerta-indicador.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de indicadores
@ -97,7 +104,12 @@ export function createIndicadorController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -258,7 +270,12 @@ export function createIndicadorController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -341,7 +358,12 @@ export function createIndicadorController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -25,7 +25,14 @@ import { TipoInspeccion } from '../entities/tipo-inspeccion.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de inspecciones
@ -107,7 +114,12 @@ export function createInspeccionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -277,7 +289,12 @@ export function createInspeccionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -26,7 +26,14 @@ import { PermisoDocumento } from '../entities/permiso-documento.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de permisos de trabajo

View File

@ -37,7 +37,14 @@ import { Auditoria, ResultadoAuditoria } from '../entities/auditoria.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de STPS

View File

@ -177,7 +177,10 @@ export class AmbientalService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -242,7 +245,10 @@ export class AmbientalService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -341,7 +347,10 @@ export class AmbientalService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -441,7 +450,10 @@ export class AmbientalService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -526,7 +538,10 @@ export class AmbientalService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -77,12 +77,10 @@ export class CapacitacionService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -131,7 +131,10 @@ export class EppService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -249,7 +252,10 @@ export class EppService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -143,12 +143,10 @@ export class IncidenteService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -91,7 +91,10 @@ export class IndicadorService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -187,7 +190,10 @@ export class IndicadorService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -248,7 +254,10 @@ export class IndicadorService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -143,12 +143,10 @@ export class InspeccionService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -258,12 +256,10 @@ export class InspeccionService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -133,7 +133,10 @@ export class PermisoTrabajoService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -195,7 +195,10 @@ export class StpsService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -559,7 +562,10 @@ export class StpsService {
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -18,7 +18,14 @@ import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createAsignacionController(dataSource: DataSource): Router {
const router = Router();
@ -70,7 +77,16 @@ export function createAsignacionController(dataSource: DataSource): Router {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
res.status(200).json({
success: true,
data: result.data,
pagination: {
total: result.meta.total,
page: result.meta.page,
limit: result.meta.limit,
totalPages: result.meta.totalPages,
},
});
} catch (error) {
next(error);
}

View File

@ -17,7 +17,14 @@ import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createDerechohabienteController(dataSource: DataSource): Router {
const router = Router();
@ -68,7 +75,16 @@ export function createDerechohabienteController(dataSource: DataSource): Router
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
res.status(200).json({
success: true,
data: result.data,
pagination: {
total: result.meta.total,
page: result.meta.page,
limit: result.meta.limit,
totalPages: result.meta.totalPages,
},
});
} catch (error) {
next(error);
}

View File

@ -19,7 +19,11 @@ import { ConsumoObra } from '../entities/consumo-obra.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de consumos
@ -78,7 +82,12 @@ export function createConsumoObraController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -22,7 +22,11 @@ import { RequisicionLinea } from '../entities/requisicion-linea.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de requisiciones
@ -81,7 +85,12 @@ export function createRequisicionController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -8,7 +8,19 @@
import { Repository, FindOptionsWhere } from 'typeorm';
import { ConsumoObra } from '../entities/consumo-obra.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateConsumoDto {
fraccionamientoId: string;
@ -94,12 +106,10 @@ export class ConsumoObraService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -10,7 +10,19 @@
import { Repository, FindOptionsWhere } from 'typeorm';
import { RequisicionObra, RequisitionStatus } from '../entities/requisicion-obra.entity';
import { RequisicionLinea } from '../entities/requisicion-linea.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateRequisicionDto {
fraccionamientoId: string;
@ -98,12 +110,10 @@ export class RequisicionService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -17,7 +17,14 @@ import { FotoAvance } from '../entities/foto-avance.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de avances de obra
@ -77,7 +84,12 @@ export function createAvanceObraController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.meta.total,
page: result.meta.page,
limit: result.meta.limit,
totalPages: result.meta.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -15,7 +15,14 @@ import { BitacoraObra } from '../entities/bitacora-obra.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Service context for multi-tenant operations
*/
interface ServiceContext {
tenantId: string;
userId?: string;
}
/**
* Crear router de bitácora de obra
@ -77,7 +84,12 @@ export function createBitacoraObraController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.meta.total,
page: result.meta.page,
limit: result.meta.limit,
totalPages: result.meta.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -18,7 +18,11 @@ import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createComparativoController(dataSource: DataSource): Router {
const router = Router();
@ -77,7 +81,16 @@ export function createComparativoController(dataSource: DataSource): Router {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
res.status(200).json({
success: true,
data: result.data,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
}

View File

@ -10,7 +10,19 @@ import { Repository, FindOptionsWhere } from 'typeorm';
import { ComparativoCotizaciones, ComparativoStatus } from '../entities/comparativo-cotizaciones.entity';
import { ComparativoProveedor } from '../entities/comparativo-proveedor.entity';
import { ComparativoProducto } from '../entities/comparativo-producto.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateComparativoDto {
requisicionId?: string;
@ -99,12 +111,10 @@ export class ComparativoService {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -18,23 +18,25 @@ import {
} from '../services/dashboard.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Dashboard } from '../entities/dashboard.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createDashboardController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const dashboardRepository = dataSource.getRepository(Dashboard);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const dashboardService = new DashboardService(dashboardRepository, dataSource);
const dashboardService = new DashboardService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -76,7 +78,12 @@ export function createDashboardController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -11,23 +11,25 @@ import { DataSource } from 'typeorm';
import { KpiService, CreateKpiSnapshotDto, KpiFilters } from '../services/kpi.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { KpiSnapshot } from '../entities/kpi-snapshot.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createKpiController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const kpiRepository = dataSource.getRepository(KpiSnapshot);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const kpiService = new KpiService(kpiRepository);
const kpiService = new KpiService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -69,7 +71,12 @@ export function createKpiController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -11,23 +11,25 @@ import { DataSource } from 'typeorm';
import { ReportService, CreateReportDto, UpdateReportDto, ReportFilters, ExecuteReportDto } from '../services/report.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Report } from '../entities/report.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createReportController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const reportRepository = dataSource.getRepository(Report);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const reportService = new ReportService(reportRepository, dataSource);
const reportService = new ReportService(dataSource);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
@ -67,7 +69,12 @@ export function createReportController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);
@ -279,7 +286,12 @@ export function createReportController(dataSource: DataSource): Router {
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
} catch (error) {
next(error);

View File

@ -7,10 +7,22 @@
*/
import { Repository, DataSource } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Dashboard, DashboardType, DashboardVisibility } from '../entities/dashboard.entity';
import { DashboardWidget, WidgetType, DataSourceType } from '../entities/dashboard-widget.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateDashboardDto {
code: string;
name: string;
@ -66,17 +78,61 @@ export interface DashboardFilters {
search?: string;
}
export class DashboardService extends BaseService<Dashboard> {
export class DashboardService {
private repository: Repository<Dashboard>;
private widgetRepository: Repository<DashboardWidget>;
constructor(
repository: Repository<Dashboard>,
dataSource: DataSource
) {
super(repository);
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Dashboard);
this.widgetRepository = dataSource.getRepository(DashboardWidget);
}
/**
* Buscar por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Dashboard | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
});
}
/**
* Crear entidad
*/
async create(ctx: ServiceContext, data: Partial<Dashboard>): Promise<Dashboard> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
});
return this.repository.save(entity);
}
/**
* Actualizar entidad
*/
async update(ctx: ServiceContext, id: string, data: Partial<Dashboard>): Promise<Dashboard | null> {
await this.repository.update(
{ id, tenantId: ctx.tenantId, deletedAt: null } as any,
data as any
);
return this.findById(ctx, id);
}
/**
* Soft delete
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId, deletedAt: null } as any,
{ deletedAt: new Date() } as any
);
return (result.affected ?? 0) > 0;
}
/**
* Buscar dashboards con filtros
*/

View File

@ -6,10 +6,22 @@
* @module Reports
*/
import { Repository, LessThanOrEqual } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Repository, DataSource, LessThanOrEqual } from 'typeorm';
import { KpiSnapshot, KpiCategory, KpiPeriodType, TrendDirection } from '../entities/kpi-snapshot.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateKpiSnapshotDto {
kpiCode: string;
kpiName: string;
@ -66,9 +78,34 @@ export interface KpiSummary {
}[];
}
export class KpiService extends BaseService<KpiSnapshot> {
constructor(repository: Repository<KpiSnapshot>) {
super(repository);
export class KpiService {
private repository: Repository<KpiSnapshot>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(KpiSnapshot);
}
/**
* Buscar por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<KpiSnapshot | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as any,
});
}
/**
* Crear entidad
*/
async create(ctx: ServiceContext, data: Partial<KpiSnapshot>): Promise<KpiSnapshot> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
});
return this.repository.save(entity);
}
/**
@ -110,12 +147,10 @@ export class KpiService extends BaseService<KpiSnapshot> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -6,11 +6,23 @@
* @module Reports
*/
import { Repository, DataSource } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { DataSource, Repository, IsNull } from 'typeorm';
import { Report, ReportType, ReportFormat, ReportFrequency } from '../entities/report.entity';
import { ReportExecution, ExecutionStatus } from '../entities/report-execution.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateReportDto {
code: string;
name: string;
@ -49,17 +61,57 @@ export interface ExecuteReportDto {
distribute?: boolean;
}
export class ReportService extends BaseService<Report> {
export class ReportService {
private repository: Repository<Report>;
private executionRepository: Repository<ReportExecution>;
constructor(
repository: Repository<Report>,
dataSource: DataSource
) {
super(repository);
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Report);
this.executionRepository = dataSource.getRepository(ReportExecution);
}
/**
* Buscar reporte por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Report | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
});
}
/**
* Crear reporte
*/
async create(ctx: ServiceContext, data: CreateReportDto & { isActive: boolean; isSystem: boolean; executionCount: number }): Promise<Report> {
const report = this.repository.create({
...data,
tenantId: ctx.tenantId,
});
return this.repository.save(report);
}
/**
* Actualizar reporte
*/
async update(ctx: ServiceContext, id: string, data: Partial<UpdateReportDto>): Promise<Report | null> {
await this.repository.update(
{ id, tenantId: ctx.tenantId, deletedAt: IsNull() },
data
);
return this.findById(ctx, id);
}
/**
* Soft delete reporte
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId, deletedAt: IsNull() },
{ deletedAt: new Date() }
);
return result.affected! > 0;
}
/**
* Buscar reportes con filtros
*/
@ -96,12 +148,10 @@ export class ReportService extends BaseService<Report> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
@ -288,12 +338,10 @@ export class ReportService extends BaseService<Report> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@ -1,6 +1,15 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../errors/index.js';
import { PaginationMeta } from '../types/index.js';
/**
* BaseService - Base service class (deprecated)
*
* NOTE: This base class is deprecated. New services should be standalone
* and use TypeORM Repository pattern directly with DataSource injection.
*
* The ServiceContext and PaginatedResult interfaces are still exported
* for backwards compatibility with existing code.
*
* @deprecated Use standalone service pattern with TypeORM Repository instead
* @module Shared
*/
/**
* Contexto de servicio para multi-tenant
@ -36,7 +45,6 @@ export interface BasePaginationFilters {
* Opciones para construcción de queries
*/
export interface QueryOptions {
client?: PoolClient;
includeDeleted?: boolean;
}
@ -55,28 +63,41 @@ export interface BaseServiceConfig {
/**
* Clase base abstracta para servicios CRUD con soporte multi-tenant
*
* Proporciona implementaciones reutilizables para:
* - Paginación con filtros
* - Búsqueda por texto
* - CRUD básico
* - Soft delete
* - Transacciones
* @deprecated Use standalone service pattern with TypeORM Repository instead
*
* @example
* Example of new pattern:
* ```typescript
* class PartnersService extends BaseService<Partner, CreatePartnerDto, UpdatePartnerDto> {
* protected config: BaseServiceConfig = {
* tableName: 'partners',
* schema: 'core',
* selectFields: 'id, tenant_id, name, email, phone, created_at',
* searchFields: ['name', 'email', 'tax_id'],
* defaultSortField: 'name',
* softDelete: true,
* };
* import { DataSource, Repository, IsNull } from 'typeorm';
*
* interface ServiceContext {
* tenantId: string;
* userId?: string;
* }
*
* interface PaginatedResult<T> {
* data: T[];
* total: number;
* page: number;
* limit: number;
* totalPages: number;
* }
*
* export class MyService {
* private repository: Repository<MyEntity>;
*
* constructor(dataSource: DataSource) {
* this.repository = dataSource.getRepository(MyEntity);
* }
*
* async findById(ctx: ServiceContext, id: string): Promise<MyEntity | null> {
* return this.repository.findOne({
* where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
* });
* }
* }
* ```
*/
export abstract class BaseService<T, CreateDto, UpdateDto> {
export abstract class BaseService<T, _CreateDto = unknown, _UpdateDto = unknown> {
protected abstract config: BaseServiceConfig;
/**
@ -86,345 +107,16 @@ export abstract class BaseService<T, CreateDto, UpdateDto> {
return `${this.config.schema}.${this.config.tableName}`;
}
/**
* Obtiene todos los registros con paginación y filtros
*/
async findAll(
tenantId: string,
filters: BasePaginationFilters & Record<string, any> = {},
options: QueryOptions = {}
): Promise<PaginatedResult<T>> {
const {
page = 1,
limit = 20,
sortBy = this.config.defaultSortField || 'created_at',
sortOrder = 'desc',
search,
...customFilters
} = filters;
const offset = (page - 1) * limit;
const params: any[] = [tenantId];
let paramIndex = 2;
// Construir WHERE clause
let whereClause = 'WHERE tenant_id = $1';
// Soft delete
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
// Búsqueda por texto
if (search && this.config.searchFields?.length) {
const searchConditions = this.config.searchFields
.map(field => `${field} ILIKE $${paramIndex}`)
.join(' OR ');
whereClause += ` AND (${searchConditions})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filtros custom
for (const [key, value] of Object.entries(customFilters)) {
if (value !== undefined && value !== null && value !== '') {
whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
}
}
// Validar sortBy para prevenir SQL injection
const safeSortBy = this.sanitizeFieldName(sortBy);
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Query de conteo
const countSql = `
SELECT COUNT(*) as count
FROM ${this.fullTableName}
${whereClause}
`;
// Query de datos
const dataSql = `
SELECT ${this.config.selectFields}
FROM ${this.fullTableName}
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`;
if (options.client) {
const [countResult, dataResult] = await Promise.all([
options.client.query(countSql, params),
options.client.query(dataSql, [...params, limit, offset]),
]);
const total = parseInt(countResult.rows[0]?.count || '0', 10);
return {
data: dataResult.rows as T[],
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
const [countRows, dataRows] = await Promise.all([
query<{ count: string }>(countSql, params),
query<T>(dataSql, [...params, limit, offset]),
]);
const total = parseInt(countRows[0]?.count || '0', 10);
return {
data: dataRows,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Obtiene un registro por ID
*/
async findById(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<T | null> {
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
const sql = `
SELECT ${this.config.selectFields}
FROM ${this.fullTableName}
${whereClause}
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows[0] as T || null;
}
const rows = await query<T>(sql, [id, tenantId]);
return rows[0] || null;
}
/**
* Obtiene un registro por ID o lanza error si no existe
*/
async findByIdOrFail(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<T> {
const entity = await this.findById(id, tenantId, options);
if (!entity) {
throw new NotFoundError(`${this.config.tableName} with id ${id} not found`);
}
return entity;
}
/**
* Verifica si existe un registro
*/
async exists(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<boolean> {
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
const sql = `
SELECT 1 FROM ${this.fullTableName}
${whereClause}
LIMIT 1
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId]);
return rows.length > 0;
}
/**
* Soft delete de un registro
*/
async softDelete(
id: string,
tenantId: string,
userId: string,
options: QueryOptions = {}
): Promise<boolean> {
if (!this.config.softDelete) {
throw new ValidationError('Soft delete not enabled for this entity');
}
const sql = `
UPDATE ${this.fullTableName}
SET deleted_at = CURRENT_TIMESTAMP,
deleted_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
RETURNING id
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId, userId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId, userId]);
return rows.length > 0;
}
/**
* Hard delete de un registro
*/
async hardDelete(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<boolean> {
const sql = `
DELETE FROM ${this.fullTableName}
WHERE id = $1 AND tenant_id = $2
RETURNING id
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId]);
return rows.length > 0;
}
/**
* Cuenta registros con filtros
*/
async count(
tenantId: string,
filters: Record<string, any> = {},
options: QueryOptions = {}
): Promise<number> {
const params: any[] = [tenantId];
let paramIndex = 2;
let whereClause = 'WHERE tenant_id = $1';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null) {
whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
}
}
const sql = `
SELECT COUNT(*) as count
FROM ${this.fullTableName}
${whereClause}
`;
if (options.client) {
const result = await options.client.query(sql, params);
return parseInt(result.rows[0]?.count || '0', 10);
}
const rows = await query<{ count: string }>(sql, params);
return parseInt(rows[0]?.count || '0', 10);
}
/**
* Ejecuta una función dentro de una transacción
*/
protected async withTransaction<R>(
fn: (client: PoolClient) => Promise<R>
): Promise<R> {
const client = await getClient();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Sanitiza nombre de campo para prevenir SQL injection
*/
protected sanitizeFieldName(field: string): string {
// Solo permite caracteres alfanuméricos y guiones bajos
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
return this.config.defaultSortField || 'created_at';
}
return field;
}
/**
* Construye un INSERT dinámico
*/
protected buildInsertQuery(
data: Record<string, any>,
additionalFields: Record<string, any> = {}
): { sql: string; params: any[] } {
const allData = { ...data, ...additionalFields };
const fields = Object.keys(allData);
const values = Object.values(allData);
const placeholders = fields.map((_, i) => `$${i + 1}`);
const sql = `
INSERT INTO ${this.fullTableName} (${fields.join(', ')})
VALUES (${placeholders.join(', ')})
RETURNING ${this.config.selectFields}
`;
return { sql, params: values };
}
/**
* Construye un UPDATE dinámico
*/
protected buildUpdateQuery(
id: string,
tenantId: string,
data: Record<string, any>
): { sql: string; params: any[] } {
const fields = Object.keys(data).filter(k => data[k] !== undefined);
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`);
const values = fields.map(f => data[f]);
// Agregar updated_at automáticamente
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
const paramIndex = fields.length + 1;
const sql = `
UPDATE ${this.fullTableName}
SET ${setClauses.join(', ')}
WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
RETURNING ${this.config.selectFields}
`;
return { sql, params: [...values, id, tenantId] };
}
/**
* Redondea a N decimales
*/